Skip to content

Commit

Permalink
Merge pull request #635 from googlefonts/compile-variable-features
Browse files Browse the repository at this point in the history
Compile variable features
  • Loading branch information
anthrotype authored Jan 11, 2024
2 parents 1deb648 + a782bbf commit 657cb64
Show file tree
Hide file tree
Showing 51 changed files with 2,182 additions and 93 deletions.
29 changes: 29 additions & 0 deletions Lib/ufo2ft/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"compileVariableCFF2s",
]

try:
from ._version import version as __version__
except ImportError:
__version__ = "0.0.0+unknown"


def compileTTF(ufo, **kwargs):
"""Create FontTools TrueType font from a UFO.
Expand Down Expand Up @@ -168,6 +173,30 @@ def compileVariableTTFs(designSpaceDoc, **kwargs):


def compileInterpolatableTTFsFromDS(designSpaceDoc, **kwargs):
"""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.
If the Designspace contains a "public.skipExportGlyphs" lib key, these
glyphs will not be exported to the final font. If these glyphs are used as
components in any other glyph, those components get decomposed. If the lib
key doesn't exist in the Designspace, all glyphs are exported (keys in
individual UFOs are ignored). UFO groups and kerning will be pruned of
skipped glyphs.
The DesignSpaceDocument should contain SourceDescriptor objects with 'font'
attribute set to an already loaded defcon.Font object (or compatible UFO
Font class). If 'font' attribute is unset or None, an AttributeError exception
is thrown.
Return a copy of the DesignSpaceDocument object (or the same one if
inplace=True) with the source's 'font' attribute set to the corresponding
TTFont instance.
For sources that have the 'layerName' attribute defined, the corresponding TTFont
object will contain only a minimum set of tables ("head", "hmtx", "glyf", "loca",
"maxp", "post" and "vmtx"), and no OpenType layout tables.
"""
return InterpolatableTTFCompiler(**kwargs).compile_designspace(designSpaceDoc)


Expand Down
95 changes: 90 additions & 5 deletions Lib/ufo2ft/_compilers/baseCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
from dataclasses import dataclass
from typing import Callable, Optional, Type

from fontTools import varLib
from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts
from fontTools.misc.loggingTools import Timer
from fontTools.otlLib.optimize.gpos import GPOS_COMPACT_MODE_ENV_KEY

from ufo2ft.constants import MTI_FEATURES_PREFIX
from ufo2ft.errors import InvalidDesignSpaceData
from ufo2ft.featureCompiler import FeatureCompiler, MtiFeatureCompiler
from ufo2ft.featureCompiler import (
FeatureCompiler,
MtiFeatureCompiler,
VariableFeatureCompiler,
_featuresCompatible,
)
from ufo2ft.postProcessor import PostProcessor
from ufo2ft.util import (
_notdefGlyphFallback,
Expand Down Expand Up @@ -51,6 +57,7 @@ def compile(self, ufo):
with self.timer("preprocess UFO"):
glyphSet = self.preprocess(ufo)
with self.timer("compile a basic TTF"):
self.logger.info("Building OpenType tables")
font = self.compileOutlines(ufo, glyphSet)
if self.layerName is None and not self.skipFeatureCompilation:
self.compileFeatures(ufo, font, glyphSet=glyphSet)
Expand Down Expand Up @@ -169,8 +176,13 @@ class BaseInterpolatableCompiler(BaseCompiler):
"maxp", "post" and "vmtx"), and no OpenType layout tables.
"""

def compile_designspace(self, designSpaceDoc):
ufos = self._pre_compile_designspace(designSpaceDoc)
ttfs = self.compile(ufos)
return self._post_compile_designspace(designSpaceDoc, ttfs)

def _pre_compile_designspace(self, designSpaceDoc):
ufos, self.layerNames = [], []
ufos, self.glyphSets, self.layerNames = [], [], []
for source in designSpaceDoc.sources:
if source.font is None:
raise AttributeError(
Expand Down Expand Up @@ -246,7 +258,16 @@ def _compileNeededSources(self, designSpaceDoc):
if source.name in sourcesToCompile:
sourcesByName[source.name] = source

# If the feature files are compatible between the sources, we can save
# time by building a variable feature file right at the end.
can_optimize_features = self.variableFeatures and _featuresCompatible(
designSpaceDoc
)
if can_optimize_features:
self.logger.info("Features are compatible across masters; building later")

originalSources = {}
originalGlyphsets = {}

# Compile all needed sources in each interpolable subspace to make sure
# they're all compatible; that also ensures that sub-vfs within the same
Expand All @@ -265,6 +286,7 @@ def _compileNeededSources(self, designSpaceDoc):
self.useProductionNames = False
save_postprocessor = self.postProcessorClass
self.postProcessorClass = None
self.skipFeatureCompilation = can_optimize_features
try:
ttfDesignSpace = self.compile_designspace(subDoc)
finally:
Expand All @@ -274,33 +296,96 @@ def _compileNeededSources(self, designSpaceDoc):
self.useProductionNames = save_production_names

# Stick TTFs back into original big DS
for ttfSource in ttfDesignSpace.sources:
for ttfSource, glyphSet in zip(ttfDesignSpace.sources, self.glyphSets):
if can_optimize_features:
originalSources[ttfSource.name] = sourcesByName[ttfSource.name].font
sourcesByName[ttfSource.name].font = ttfSource.font
originalGlyphsets[ttfSource.name] = glyphSet

return vfNameToBaseUfo, originalSources
return (
vfNameToBaseUfo,
can_optimize_features,
originalSources,
originalGlyphsets,
)

def compile_variable(self, designSpaceDoc):
if not self.inplace:
designSpaceDoc = designSpaceDoc.deepcopyExceptFonts()

(
vfNameToBaseUfo,
buildVariableFeatures,
originalSources,
originalGlyphsets,
) = self._compileNeededSources(designSpaceDoc)

if not vfNameToBaseUfo:
return {}

self.logger.info("Building variable TTF fonts: %s", ", ".join(vfNameToBaseUfo))
vfNames = list(vfNameToBaseUfo.keys())
self.logger.info(
"Building variable font%s: %s",
"s" if len(vfNames) > 1 else "",
", ".join(vfNames),
)

excludeVariationTables = self.excludeVariationTables
if buildVariableFeatures:
# Skip generating feature variations in varLib; we are handling
# the feature variations as part of compiling variable features,
# which we'll do later, so we don't need to produce them here.
excludeVariationTables = set(excludeVariationTables) | {"GSUB"}

with self.timer("merge fonts to variable"):
vfNameToTTFont = self._merge(designSpaceDoc, excludeVariationTables)

if buildVariableFeatures:
self.compile_all_variable_features(
designSpaceDoc, vfNameToTTFont, originalSources, originalGlyphsets
)
for vfName, varfont in list(vfNameToTTFont.items()):
vfNameToTTFont[vfName] = self.postprocess(
varfont, vfNameToBaseUfo[vfName], glyphSet=None
)

return vfNameToTTFont

def compile_all_variable_features(
self,
designSpaceDoc,
vfNameToTTFont,
originalSources,
originalGlyphsets,
debugFeatureFile=False,
):
interpolableSubDocs = [
subDoc for _location, subDoc in splitInterpolable(designSpaceDoc)
]
for subDoc in interpolableSubDocs:
for vfName, vfDoc in splitVariableFonts(subDoc):
if vfName not in vfNameToTTFont:
continue
ttFont = vfNameToTTFont[vfName]
# vfDoc is now full of TTFs, create a UFO-sourced equivalent
ufoDoc = vfDoc.deepcopyExceptFonts()
for ttfSource, ufoSource in zip(vfDoc.sources, ufoDoc.sources):
ufoSource.font = originalSources[ttfSource.name]
defaultGlyphset = originalGlyphsets[ufoDoc.findDefault().name]
self.logger.info(f"Compiling variable features for {vfName}")
self.compile_variable_features(ufoDoc, ttFont, defaultGlyphset)

def compile_variable_features(self, designSpaceDoc, ttFont, glyphSet):
default_ufo = designSpaceDoc.findDefault().font

featureCompiler = VariableFeatureCompiler(
default_ufo, designSpaceDoc, ttFont=ttFont, glyphSet=glyphSet
)
featureCompiler.compile()

if self.debugFeatureFile:
if hasattr(featureCompiler, "writeFeatures"):
featureCompiler.writeFeatures(self.debugFeatureFile)

# Add back feature variations, as the code above would overwrite them.
varLib.addGSUBFeatureVariations(ttFont, designSpaceDoc)
22 changes: 13 additions & 9 deletions Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ufo2ft.constants import SPARSE_OTF_MASTER_TABLES, CFFOptimization
from ufo2ft.outlineCompiler import OutlineOTFCompiler
from ufo2ft.preProcessor import OTFPreProcessor
from ufo2ft.util import prune_unknown_kwargs

from .baseCompiler import BaseInterpolatableCompiler
from .otfCompiler import OTFCompiler
Expand All @@ -28,31 +29,34 @@ class InterpolatableOTFCompiler(OTFCompiler, BaseInterpolatableCompiler):
skipFeatureCompilation: bool = False
excludeVariationTables: tuple = ()

def compile_designspace(self, designSpaceDoc):
self._pre_compile_designspace(designSpaceDoc)
# 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 source in designSpaceDoc.sources:
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=source.layerName,
layerName=layerName,
removeOverlaps=False,
overlapsBackend=None,
optimizeCFF=CFFOptimization.NONE,
_tables=SPARSE_OTF_MASTER_TABLES if source.layerName else None,
_tables=SPARSE_OTF_MASTER_TABLES if layerName else None,
),
}
# Remove interpolatable-specific args
del args["variableFontNames"]
del args["excludeVariationTables"]
args = prune_unknown_kwargs(args, OTFCompiler)
compiler = OTFCompiler(**args)
self.extraSubstitutions = save_extraSubstitutions
otfs.append(compiler.compile(source.font))
return self._post_compile_designspace(designSpaceDoc, otfs)
otfs.append(compiler.compile(ufo))
self.glyphSets.append(compiler._glyphSet)
return otfs

def _merge(self, designSpaceDoc, excludeVariationTables):
return varLib.build_many(
Expand Down
9 changes: 2 additions & 7 deletions Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ def compile(self, ufos):
if self.layerNames is None:
self.layerNames = [None] * len(ufos)
assert len(ufos) == len(self.layerNames)
glyphSets = self.preprocess(ufos)
self.glyphSets = self.preprocess(ufos)

for ufo, glyphSet, layerName in zip(ufos, glyphSets, self.layerNames):
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):
Expand Down Expand Up @@ -78,11 +78,6 @@ def compileOutlines(self, ufo, glyphSet, layerName=None):
outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
return outlineCompiler.compile()

def compile_designspace(self, designSpaceDoc):
ufos = self._pre_compile_designspace(designSpaceDoc)
ttfs = self.compile(ufos)
return self._post_compile_designspace(designSpaceDoc, ttfs)

def _merge(self, designSpaceDoc, excludeVariationTables):
return varLib.build_many(
designSpaceDoc,
Expand Down
1 change: 1 addition & 0 deletions Lib/ufo2ft/_compilers/otfCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ def postprocess(self, font, ufo, glyphSet):
kwargs = prune_unknown_kwargs(self.__dict__, postProcessor.process)
kwargs["optimizeCFF"] = self.optimizeCFF >= CFFOptimization.SUBROUTINIZE
font = postProcessor.process(**kwargs)
self._glyphSet = glyphSet
return font
2 changes: 1 addition & 1 deletion Lib/ufo2ft/_compilers/ttfCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class TTFCompiler(BaseCompiler):
dropImpliedOnCurves: bool = False
allQuadratic: bool = True

def compileOutlines(self, ufo, glyphSet, layerName=None):
def compileOutlines(self, ufo, glyphSet):
kwargs = prune_unknown_kwargs(self.__dict__, self.outlineCompilerClass)
kwargs["glyphDataFormat"] = 0 if self.allQuadratic else 1
outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
Expand Down
1 change: 1 addition & 0 deletions Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ class VariableCFF2sCompiler(InterpolatableOTFCompiler):
cffVersion: int = 2
optimizeCFF: CFFOptimization = CFFOptimization.SPECIALIZE
excludeVariationTables: tuple = ()
variableFeatures: bool = True
2 changes: 1 addition & 1 deletion Lib/ufo2ft/_compilers/variableTTFsCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ class VariableTTFsCompiler(InterpolatableTTFCompiler):
autoUseMyMetrics: bool = True
dropImpliedOnCurves: bool = False
allQuadratic: bool = True
pass
variableFeatures: bool = True
58 changes: 58 additions & 0 deletions Lib/ufo2ft/featureCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from tempfile import NamedTemporaryFile

from fontTools import mtiLib
from fontTools.designspaceLib import DesignSpaceDocument, SourceDescriptor
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.feaLib.error import FeatureLibError, IncludedFeaNotFound
from fontTools.feaLib.parser import Parser
Expand Down Expand Up @@ -102,6 +103,10 @@ def __init__(self, ufo, ttFont=None, glyphSet=None, extraSubstitutions=None):

glyphOrder = ttFont.getGlyphOrder()
if glyphSet is not None:
if set(glyphOrder) != set(glyphSet.keys()):
print("Glyph order incompatible")
print("In UFO but not in font:", set(glyphSet.keys()) - set(glyphOrder))
print("In font but not in UFO:", set(glyphOrder) - set(glyphSet.keys()))
assert set(glyphOrder) == set(glyphSet.keys())
else:
glyphSet = ufo
Expand Down Expand Up @@ -407,3 +412,56 @@ def warn_about_miscased_insertion_markers(
text,
pattern_case.pattern,
)


class VariableFeatureCompiler(FeatureCompiler):
"""Generate a variable feature file and compile OpenType tables from a
designspace file.
"""

def __init__(
self,
ufo,
designspace,
ttFont=None,
glyphSet=None,
featureWriters=None,
**kwargs,
):
self.designspace = designspace
super().__init__(ufo, ttFont, glyphSet, featureWriters, **kwargs)

def setupFeatures(self):
if self.featureWriters:
featureFile = parseLayoutFeatures(self.ufo)

for writer in self.featureWriters:
writer.write(self.designspace, featureFile, compiler=self)

# stringify AST to get correct line numbers in error messages
self.features = featureFile.asFea()
else:
# no featureWriters, simply read existing features' text
self.features = self.ufo.features.text or ""


def _featuresCompatible(designSpaceDoc: DesignSpaceDocument) -> bool:
"""Returns whether the features of the individual source UFOs are the same.
NOTE: Only compares the feature file text inside the source UFO and does not
follow imports. This will suffice as long as no external feature file is
using variable syntax and all sources are stored n the same parent folder
(so the same includes point to the same files).
"""

assert all(hasattr(source.font, "features") for source in designSpaceDoc.sources)

def transform(f: SourceDescriptor) -> str:
# Strip comments
text = re.sub("(?m)#.*$", "", f.font.features.text or "")
# Strip extraneous whitespace
text = re.sub(r"\s+", " ", text)
return text

first = transform(designSpaceDoc.sources[0])
return all(transform(s) == first for s in designSpaceDoc.sources[1:])
Loading

0 comments on commit 657cb64

Please sign in to comment.