diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e2e02e8c1..267dceb96 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -114,7 +114,7 @@ jobs: with: name: artifact path: dist - - uses: pypa/gh-action-pypi-publish@v1.8.10 + - uses: pypa/gh-action-pypi-publish@v1.8.11 with: user: __token__ password: ${{ secrets.PYPI_PASSWORD }} diff --git a/Doc/docs-requirements.txt b/Doc/docs-requirements.txt index f8f93c157..3a8f79fcf 100644 --- a/Doc/docs-requirements.txt +++ b/Doc/docs-requirements.txt @@ -1,4 +1,4 @@ sphinx==7.2.6 -sphinx_rtd_theme==1.3.0 -reportlab==4.0.6 +sphinx_rtd_theme==2.0.0 +reportlab==4.0.7 freetype-py==2.4.0 diff --git a/Doc/source/designspaceLib/v5_class_diagram.png b/Doc/source/designspaceLib/v5_class_diagram.png index 7c75bcb92..38bcf376a 100644 Binary files a/Doc/source/designspaceLib/v5_class_diagram.png and b/Doc/source/designspaceLib/v5_class_diagram.png differ diff --git a/Doc/source/designspaceLib/v5_class_diagram.puml b/Doc/source/designspaceLib/v5_class_diagram.puml index 31f9e9c36..1cc008225 100644 --- a/Doc/source/designspaceLib/v5_class_diagram.puml +++ b/Doc/source/designspaceLib/v5_class_diagram.puml @@ -112,7 +112,7 @@ class Source { + path: str + layerName: Optional[str] + <> location: Location -+ <> designLocation: AnisotropicLocation ++ <> designLocation: SimpleLocation .... + font: Optional[Font] .... diff --git a/Doc/source/designspaceLib/xml.rst b/Doc/source/designspaceLib/xml.rst index eb92a9de9..35d1df313 100644 --- a/Doc/source/designspaceLib/xml.rst +++ b/Doc/source/designspaceLib/xml.rst @@ -438,8 +438,8 @@ glyphname pairs: the glyphs that need to be substituted. For a rule to be trigge See the following issues for more information: `fontTools#1371 `__ `fontTools#2050 `__ - - If you want to use a different feature altogether, e.g. ``calt``, - use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag`` + - If you want to use a different feature(s) altogether, e.g. ``calt``, + use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``. .. code:: xml @@ -450,6 +450,9 @@ glyphname pairs: the glyphs that need to be substituted. For a rule to be trigge + This can also take a comma-separated list of feature tags, e.g. ``salt,ss01``, + if you wish the same rules to be applied with several features. + ```` element diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index d02080fb8..51240777d 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -3,6 +3,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.45.2.dev0" +version = __version__ = "4.46.1.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/Lib/fontTools/afmLib.py b/Lib/fontTools/afmLib.py index 935a1e8e0..e89646951 100644 --- a/Lib/fontTools/afmLib.py +++ b/Lib/fontTools/afmLib.py @@ -82,7 +82,10 @@ kernRE = re.compile( # regular expressions to parse composite info lines of the form: # Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ; compositeRE = re.compile( - r"([.A-Za-z0-9_]+)" r"\s+" r"(\d+)" r"\s*;\s*" # char name # number of parts + r"([.A-Za-z0-9_]+)" # char name + r"\s+" + r"(\d+)" # number of parts + r"\s*;\s*" ) componentRE = re.compile( r"PCC\s+" # PPC diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py index 1c71fd002..69d4912c0 100644 --- a/Lib/fontTools/designspaceLib/__init__.py +++ b/Lib/fontTools/designspaceLib/__init__.py @@ -312,7 +312,7 @@ class SourceDescriptor(SimpleDescriptor): return self.designLocation @location.setter - def location(self, location: Optional[AnisotropicLocationDict]): + def location(self, location: Optional[SimpleLocationDict]): self.designLocation = location or {} def setFamilyName(self, familyName, languageCode="en"): @@ -329,15 +329,13 @@ class SourceDescriptor(SimpleDescriptor): """ return self.localisedFamilyName.get(languageCode) - def getFullDesignLocation( - self, doc: "DesignSpaceDocument" - ) -> AnisotropicLocationDict: + def getFullDesignLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict: """Get the complete design location of this source, from its :attr:`designLocation` and the document's axis defaults. .. versionadded:: 5.0 """ - result: AnisotropicLocationDict = {} + result: SimpleLocationDict = {} for axis in doc.axes: if axis.name in self.designLocation: result[axis.name] = self.designLocation[axis.name] diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 21ab0a5d0..a1a707b09 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -1370,6 +1370,11 @@ def _curve_curve_intersections_t( return unique_values +def _is_linelike(segment): + maybeline = _alignment_transformation(segment).transformPoints(segment) + return all(math.isclose(p[1], 0.0) for p in maybeline) + + def curveCurveIntersections(curve1, curve2): """Finds intersections between a curve and a curve. @@ -1391,6 +1396,17 @@ def curveCurveIntersections(curve1, curve2): >>> intersections[0].pt (81.7831487395506, 109.88904552375288) """ + if _is_linelike(curve1): + line1 = curve1[0], curve1[-1] + if _is_linelike(curve2): + line2 = curve2[0], curve2[-1] + return lineLineIntersections(*line1, *line2) + else: + return curveLineIntersections(curve2, line1) + elif _is_linelike(curve2): + line2 = curve2[0], curve2[-1] + return curveLineIntersections(curve1, line2) + intersection_ts = _curve_curve_intersections_t(curve1, curve2) return [ Intersection(pt=segmentPointAtT(curve1, ts[0]), t1=ts[0], t2=ts[1]) diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py index 2ed8d32ec..e24b65265 100644 --- a/Lib/fontTools/pens/recordingPen.py +++ b/Lib/fontTools/pens/recordingPen.py @@ -8,6 +8,7 @@ __all__ = [ "RecordingPen", "DecomposingRecordingPen", "RecordingPointPen", + "lerpRecordings", ] @@ -172,6 +173,34 @@ class RecordingPointPen(AbstractPointPen): drawPoints = replay +def lerpRecordings(recording1, recording2, factor=0.5): + """Linearly interpolate between two recordings. The recordings + must be decomposed, i.e. they must not contain any components. + + Factor is typically between 0 and 1. 0 means the first recording, + 1 means the second recording, and 0.5 means the average of the + two recordings. Other values are possible, and can be useful to + extrapolate. Defaults to 0.5. + + Returns a generator with the new recording. + """ + if len(recording1) != len(recording2): + raise ValueError( + "Mismatched lengths: %d and %d" % (len(recording1), len(recording2)) + ) + for (op1, args1), (op2, args2) in zip(recording1, recording2): + if op1 != op2: + raise ValueError("Mismatched operations: %s, %s" % (op1, op2)) + if op1 == "addComponent": + raise ValueError("Cannot interpolate components") + else: + mid_args = [ + (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) + for (x1, y1), (x2, y2) in zip(args1, args2) + ] + yield (op1, mid_args) + + if __name__ == "__main__": pen = RecordingPen() pen.moveTo((0, 0)) diff --git a/Lib/fontTools/ttLib/ttGlyphSet.py b/Lib/fontTools/ttLib/ttGlyphSet.py index 349cc2c73..5d188d6a1 100644 --- a/Lib/fontTools/ttLib/ttGlyphSet.py +++ b/Lib/fontTools/ttLib/ttGlyphSet.py @@ -9,6 +9,11 @@ from fontTools.misc.fixedTools import otRound from fontTools.misc.loggingTools import deprecateFunction from fontTools.misc.transform import Transform from fontTools.pens.transformPen import TransformPen, TransformPointPen +from fontTools.pens.recordingPen import ( + DecomposingRecordingPen, + lerpRecordings, + replayRecording, +) class _TTGlyphSet(Mapping): @@ -321,3 +326,52 @@ def _setCoordinates(glyph, coord, glyfTable, *, recalcBounds=True): verticalAdvanceWidth, topSideBearing, ) + + +class LerpGlyphSet(Mapping): + """A glyphset that interpolates between two other glyphsets. + + Factor is typically between 0 and 1. 0 means the first glyphset, + 1 means the second glyphset, and 0.5 means the average of the + two glyphsets. Other values are possible, and can be useful to + extrapolate. Defaults to 0.5. + """ + + def __init__(self, glyphset1, glyphset2, factor=0.5): + self.glyphset1 = glyphset1 + self.glyphset2 = glyphset2 + self.factor = factor + + def __getitem__(self, glyphname): + if glyphname in self.glyphset1 and glyphname in self.glyphset2: + return LerpGlyph(glyphname, self) + raise KeyError(glyphname) + + def __contains__(self, glyphname): + return glyphname in self.glyphset1 and glyphname in self.glyphset2 + + def __iter__(self): + set1 = set(self.glyphset1) + set2 = set(self.glyphset2) + return iter(set1.intersection(set2)) + + def __len__(self): + set1 = set(self.glyphset1) + set2 = set(self.glyphset2) + return len(set1.intersection(set2)) + + +class LerpGlyph: + def __init__(self, glyphname, glyphset): + self.glyphset = glyphset + self.glyphname = glyphname + + def draw(self, pen): + recording1 = DecomposingRecordingPen(self.glyphset.glyphset1) + self.glyphset.glyphset1[self.glyphname].draw(recording1) + recording2 = DecomposingRecordingPen(self.glyphset.glyphset2) + self.glyphset.glyphset2[self.glyphname].draw(recording2) + + factor = self.glyphset.factor + + replayRecording(lerpRecordings(recording1.value, recording2.value, factor), pen) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index b130d5b2a..46834f643 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -52,7 +52,8 @@ from .errors import VarLibError, VarLibValidationError log = logging.getLogger("fontTools.varLib") # This is a lib key for the designspace document. The value should be -# an OpenType feature tag, to be used as the FeatureVariations feature. +# a comma-separated list of OpenType feature tag(s), to be used as the +# FeatureVariations feature. # If present, the DesignSpace flag is ignored. FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag" @@ -781,7 +782,9 @@ def _merge_OTL(font, model, master_fonts, axisTags): font["GPOS"].table.remap_device_varidxes(varidx_map) -def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, featureTag): +def _add_GSUB_feature_variations( + font, axes, internal_axis_supports, rules, featureTags +): def normalize(name, value): return models.normalizeLocation({name: value}, internal_axis_supports)[name] @@ -812,7 +815,7 @@ def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, feat conditional_subs.append((region, subs)) - addFeatureVariations(font, conditional_subs, featureTag) + addFeatureVariations(font, conditional_subs, featureTags) _DesignSpaceData = namedtuple( @@ -860,7 +863,7 @@ def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True): colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) -def load_designspace(designspace): +def load_designspace(designspace, log_enabled=True): # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, # never a file path, as that's already handled by caller if hasattr(designspace, "sources"): # Assume a DesignspaceDocument @@ -908,10 +911,11 @@ def load_designspace(designspace): axis.labelNames["en"] = tostr(axis_name) axes[axis_name] = axis - log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) + if log_enabled: + log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) axisMappings = ds.axisMappings - if axisMappings: + if axisMappings and log_enabled: log.info("Mappings:\n%s", pformat(axisMappings)) # Check all master and instance locations are valid and fill in defaults @@ -941,20 +945,23 @@ def load_designspace(designspace): # Normalize master locations internal_master_locs = [o.getFullDesignLocation(ds) for o in masters] - log.info("Internal master locations:\n%s", pformat(internal_master_locs)) + if log_enabled: + log.info("Internal master locations:\n%s", pformat(internal_master_locs)) # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar internal_axis_supports = {} for axis in axes.values(): triple = (axis.minimum, axis.default, axis.maximum) internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple] - log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) + if log_enabled: + log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) normalized_master_locs = [ models.normalizeLocation(m, internal_axis_supports) for m in internal_master_locs ] - log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) + if log_enabled: + log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) # Find base master base_idx = None @@ -969,7 +976,8 @@ def load_designspace(designspace): raise VarLibValidationError( "Base master not found; no master at default location?" ) - log.info("Index of base master: %s", base_idx) + if log_enabled: + log.info("Index of base master: %s", base_idx) return _DesignSpaceData( axes, @@ -1204,11 +1212,9 @@ def build( if "cvar" not in exclude and "glyf" in vf: _merge_TTHinting(vf, model, master_fonts) if "GSUB" not in exclude and ds.rules: - featureTag = ds.lib.get( - FEAVAR_FEATURETAG_LIB_KEY, "rclt" if ds.rulesProcessingLast else "rvrn" - ) + featureTags = _feature_variations_tags(ds) _add_GSUB_feature_variations( - vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTag + vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags ) if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf): _add_CFF2(vf, model, master_fonts) @@ -1299,6 +1305,38 @@ class MasterFinder(object): return os.path.normpath(path) +def _feature_variations_tags(ds): + raw_tags = ds.lib.get( + FEAVAR_FEATURETAG_LIB_KEY, + "rclt" if ds.rulesProcessingLast else "rvrn", + ) + return sorted({t.strip() for t in raw_tags.split(",")}) + + +def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False): + """Add GSUB FeatureVariations table to variable font, based on DesignSpace rules. + + Args: + vf: A TTFont object representing the variable font. + designspace: A DesignSpaceDocument object. + featureTags: Optional feature tag(s) to use for the FeatureVariations records. + If unset, the key 'com.github.fonttools.varLib.featureVarsFeatureTag' is + looked up in the DS and used; otherwise the default is 'rclt' if + the attribute is set, else 'rvrn'. + See + log_enabled: If True, log info about DS axes and sources. Default is False, as + the same info may have already been logged as part of varLib.build. + """ + ds = load_designspace(designspace, log_enabled=log_enabled) + if not ds.rules: + return + if not featureTags: + featureTags = _feature_variations_tags(ds) + _add_GSUB_feature_variations( + vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags + ) + + def main(args=None): """Build variable fonts from a designspace file and masters""" from argparse import ArgumentParser diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index f0403d76e..a6beb5c7d 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -43,9 +43,18 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"): # ... ] # >>> addFeatureVariations(f, condSubst) # >>> f.save(dstPath) + + The `featureTag` parameter takes either a str or a iterable of str (the single str + is kept for backwards compatibility), and defines which feature(s) will be + associated with the feature variations. + Note, if this is "rvrn", then the substitution lookup will be inserted at the + beginning of the lookup list so that it is processed before others, otherwise + for any other feature tags it will be appended last. """ - processLast = featureTag != "rvrn" + # process first when "rvrn" is the only listed tag + featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag) + processLast = "rvrn" not in featureTags or len(featureTags) > 1 _checkSubstitutionGlyphsExist( glyphNames=set(font.getGlyphOrder()), @@ -60,6 +69,14 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"): ) if "GSUB" not in font: font["GSUB"] = buildGSUB() + else: + existingTags = _existingVariableFeatures(font["GSUB"].table).intersection( + featureTags + ) + if existingTags: + raise VarLibError( + f"FeatureVariations already exist for feature tag(s): {existingTags}" + ) # setup lookups lookupMap = buildSubstitutionLookups( @@ -75,7 +92,17 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"): (conditionSet, [lookupMap[s] for s in substitutions]) ) - addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTag) + addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTags) + + +def _existingVariableFeatures(table): + existingFeatureVarsTags = set() + if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None: + features = table.FeatureList.FeatureRecord + for fvr in table.FeatureVariations.FeatureVariationRecord: + for ftsr in fvr.FeatureTableSubstitution.SubstitutionRecord: + existingFeatureVarsTags.add(features[ftsr.FeatureIndex].FeatureTag) + return existingFeatureVarsTags def _checkSubstitutionGlyphsExist(glyphNames, substitutions): @@ -324,46 +351,64 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r """Low level implementation of addFeatureVariations that directly models the possibilities of the FeatureVariations table.""" - processLast = featureTag != "rvrn" + featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag) + processLast = "rvrn" not in featureTags or len(featureTags) > 1 # - # if there is no feature: + # if a feature is not present: # make empty feature # sort features, get feature index # add feature to all scripts + # if a feature is present: + # reuse feature index # make lookups # add feature variations # if table.Version < 0x00010001: table.Version = 0x00010001 # allow table.FeatureVariations - table.FeatureVariations = None # delete any existing FeatureVariations + varFeatureIndices = set() - varFeatureIndices = [] - for index, feature in enumerate(table.FeatureList.FeatureRecord): - if feature.FeatureTag == featureTag: - varFeatureIndices.append(index) + existingTags = { + feature.FeatureTag + for feature in table.FeatureList.FeatureRecord + if feature.FeatureTag in featureTags + } - if not varFeatureIndices: - varFeature = buildFeatureRecord(featureTag, []) - table.FeatureList.FeatureRecord.append(varFeature) + newTags = set(featureTags) - existingTags + if newTags: + varFeatures = [] + for featureTag in sorted(newTags): + varFeature = buildFeatureRecord(featureTag, []) + table.FeatureList.FeatureRecord.append(varFeature) + varFeatures.append(varFeature) table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord) sortFeatureList(table) - varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature) - for scriptRecord in table.ScriptList.ScriptRecord: - if scriptRecord.Script.DefaultLangSys is None: - raise VarLibError( - "Feature variations require that the script " - f"'{scriptRecord.ScriptTag}' defines a default language system." - ) - langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] - for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: - langSys.FeatureIndex.append(varFeatureIndex) - langSys.FeatureCount = len(langSys.FeatureIndex) + for varFeature in varFeatures: + varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature) - varFeatureIndices = [varFeatureIndex] + for scriptRecord in table.ScriptList.ScriptRecord: + if scriptRecord.Script.DefaultLangSys is None: + raise VarLibError( + "Feature variations require that the script " + f"'{scriptRecord.ScriptTag}' defines a default language system." + ) + langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] + for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: + langSys.FeatureIndex.append(varFeatureIndex) + langSys.FeatureCount = len(langSys.FeatureIndex) + varFeatureIndices.add(varFeatureIndex) + + if existingTags: + # indices may have changed if we inserted new features and sorted feature list + # so we must do this after the above + varFeatureIndices.update( + index + for index, feature in enumerate(table.FeatureList.FeatureRecord) + if feature.FeatureTag in existingTags + ) axisIndices = { axis.axisTag: axisIndex for axisIndex, axis in enumerate(font["fvar"].axes) @@ -380,7 +425,7 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue) conditionTable.append(ct) records = [] - for varFeatureIndex in varFeatureIndices: + for varFeatureIndex in sorted(varFeatureIndices): existingLookupIndices = table.FeatureList.FeatureRecord[ varFeatureIndex ].Feature.LookupListIndex @@ -399,7 +444,18 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r buildFeatureVariationRecord(conditionTable, records) ) - table.FeatureVariations = buildFeatureVariations(featureVariationRecords) + if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None: + if table.FeatureVariations.Version != 0x00010000: + raise VarLibError( + "Unsupported FeatureVariations table version: " + f"0x{table.FeatureVariations.Version:08x} (expected 0x00010000)." + ) + table.FeatureVariations.FeatureVariationRecord.extend(featureVariationRecords) + table.FeatureVariations.FeatureVariationCount = len( + table.FeatureVariations.FeatureVariationRecord + ) + else: + table.FeatureVariations = buildFeatureVariations(featureVariationRecords) # diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 9c568fe9a..ba5231b79 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -178,7 +178,9 @@ def _solve(tent, axisLimit, negative=False): # newUpper = peak + (1 - gain) * (upper - peak) assert axisMax <= newUpper # Because outGain > gain - if newUpper <= axisDef + (axisMax - axisDef) * 2: + # Disabled because ots doesn't like us: + # https://github.com/fonttools/fonttools/issues/3350 + if False and newUpper <= axisDef + (axisMax - axisDef) * 2: upper = newUpper if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index cd31abe72..49ab1a8d8 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -6,20 +6,25 @@ Call as: $ fonttools varLib.interpolatable font1 font2 ... """ -from fontTools.pens.basePen import AbstractPen, BasePen -from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen -from fontTools.pens.recordingPen import RecordingPen +from .interpolatableHelpers import * +from .interpolatableTestContourOrder import test_contour_order +from .interpolatableTestStartingPoint import test_starting_point +from fontTools.pens.recordingPen import ( + RecordingPen, + DecomposingRecordingPen, + lerpRecordings, +) +from fontTools.pens.transformPen import TransformPen from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen from fontTools.pens.momentsPen import OpenContourError from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation from fontTools.misc.fixedTools import floatToFixedToStr from fontTools.misc.transform import Transform -from collections import defaultdict, deque +from collections import defaultdict from types import SimpleNamespace from functools import wraps from pprint import pformat -from math import sqrt, copysign, atan2, pi -import itertools +from math import sqrt, atan2, pi import logging log = logging.getLogger("fontTools.varLib.interpolatable") @@ -30,296 +35,109 @@ DEFAULT_KINKINESS_LENGTH = 0.002 # ratio of UPEM DEFAULT_UPEM = 1000 -def _rot_list(l, k): - """Rotate list by k items forward. Ie. item at position 0 will be - at position k in returned list. Negative k is allowed.""" - return l[-k:] + l[:-k] - - -class PerContourPen(BasePen): - def __init__(self, Pen, glyphset=None): - BasePen.__init__(self, glyphset) - self._glyphset = glyphset - self._Pen = Pen - self._pen = None - self.value = [] - - def _moveTo(self, p0): - self._newItem() - self._pen.moveTo(p0) - - def _lineTo(self, p1): - self._pen.lineTo(p1) - - def _qCurveToOne(self, p1, p2): - self._pen.qCurveTo(p1, p2) - - def _curveToOne(self, p1, p2, p3): - self._pen.curveTo(p1, p2, p3) - - def _closePath(self): - self._pen.closePath() - self._pen = None - - def _endPath(self): - self._pen.endPath() - self._pen = None - - def _newItem(self): - self._pen = pen = self._Pen() - self.value.append(pen) - - -class PerContourOrComponentPen(PerContourPen): - def addComponent(self, glyphName, transformation): - self._newItem() - self.value[-1].addComponent(glyphName, transformation) - - -class SimpleRecordingPointPen(AbstractPointPen): - def __init__(self): - self.value = [] - - def beginPath(self, identifier=None, **kwargs): - pass - - def endPath(self) -> None: - pass - - def addPoint(self, pt, segmentType=None): - self.value.append((pt, False if segmentType is None else True)) - - -def _vdiff_hypot2(v0, v1): - s = 0 - for x0, x1 in zip(v0, v1): - d = x1 - x0 - s += d * d - return s - - -def _vdiff_hypot2_complex(v0, v1): - s = 0 - for x0, x1 in zip(v0, v1): - d = x1 - x0 - s += d.real * d.real + d.imag * d.imag - # This does the same but seems to be slower: - # s += (d * d.conjugate()).real - return s - - -def _hypot2_complex(d): - return d.real * d.real + d.imag * d.imag - - -def _matching_cost(G, matching): - return sum(G[i][j] for i, j in enumerate(matching)) - - -def min_cost_perfect_bipartite_matching_scipy(G): - n = len(G) - rows, cols = linear_sum_assignment(G) - assert (rows == list(range(n))).all() - return list(cols), _matching_cost(G, cols) - - -def min_cost_perfect_bipartite_matching_munkres(G): - n = len(G) - cols = [None] * n - for row, col in Munkres().compute(G): - cols[row] = col - return cols, _matching_cost(G, cols) - - -def min_cost_perfect_bipartite_matching_bruteforce(G): - n = len(G) - - if n > 6: - raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'") - - # Otherwise just brute-force - permutations = itertools.permutations(range(n)) - best = list(next(permutations)) - best_cost = _matching_cost(G, best) - for p in permutations: - cost = _matching_cost(G, p) - if cost < best_cost: - best, best_cost = list(p), cost - return best, best_cost - - -try: - from scipy.optimize import linear_sum_assignment - - min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy -except ImportError: - try: - from munkres import Munkres - - min_cost_perfect_bipartite_matching = ( - min_cost_perfect_bipartite_matching_munkres - ) - except ImportError: - min_cost_perfect_bipartite_matching = ( - min_cost_perfect_bipartite_matching_bruteforce - ) - - -def _contour_vector_from_stats(stats): - # Don't change the order of items here. - # It's okay to add to the end, but otherwise, other - # code depends on it. Search for "covariance". - size = sqrt(abs(stats.area)) - return ( - copysign((size), stats.area), - stats.meanX, - stats.meanY, - stats.stddevX * 2, - stats.stddevY * 2, - stats.correlation * size, +class Glyph: + ITEMS = ( + "recordings", + "recordingsNormalized", + "greenStats", + "controlStats", + "greenVectors", + "greenVectorsNormalized", + "controlVectors", + "nodeTypes", + "isomorphisms", + "points", + "openContours", ) + def __init__(self, glyphname, glyphset): + self.name = glyphname + for item in self.ITEMS: + setattr(self, item, []) + self._populate(glyphset) -def _matching_for_vectors(m0, m1): - n = len(m0) + def _fill_in(self, ix): + for item in self.ITEMS: + if len(getattr(self, item)) == ix: + getattr(self, item).append(None) - identity_matching = list(range(n)) + def _populate(self, glyphset): + glyph = glyphset[self.name] + self.doesnt_exist = glyph is None + if self.doesnt_exist: + return - costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0] - ( - matching, - matching_cost, - ) = min_cost_perfect_bipartite_matching(costs) - identity_cost = sum(costs[i][i] for i in range(n)) - return matching, matching_cost, identity_cost - - -def _points_characteristic_bits(points): - bits = 0 - for pt, b in reversed(points): - bits = (bits << 1) | b - return bits - - -_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4 - - -def _points_complex_vector(points): - vector = [] - if not points: - return vector - points = [complex(*pt) for pt, _ in points] - n = len(points) - assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4 - points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1]) - while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR: - points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1]) - for i in range(n): - # The weights are magic numbers. - - # The point itself - p0 = points[i] - vector.append(p0) - - # The vector to the next point - p1 = points[i + 1] - d0 = p1 - p0 - vector.append(d0 * 3) - - # The turn vector - p2 = points[i + 2] - d1 = p2 - p1 - vector.append(d1 - d0) - - # The angle to the next point, as a cross product; - # Square root of, to match dimentionality of distance. - cross = d0.real * d1.imag - d0.imag * d1.real - cross = copysign(sqrt(abs(cross)), cross) - vector.append(cross * 4) - - return vector - - -def _add_isomorphisms(points, isomorphisms, reverse): - reference_bits = _points_characteristic_bits(points) - n = len(points) - - # if points[0][0] == points[-1][0]: - # abort - - if reverse: - points = points[::-1] - bits = _points_characteristic_bits(points) - else: - bits = reference_bits - - vector = _points_complex_vector(points) - - assert len(vector) % n == 0 - mult = len(vector) // n - mask = (1 << n) - 1 - - for i in range(n): - b = ((bits << (n - i)) & mask) | (bits >> i) - if b == reference_bits: - isomorphisms.append( - (_rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse) - ) - - -def _find_parents_and_order(glyphsets, locations): - parents = [None] + list(range(len(glyphsets) - 1)) - order = list(range(len(glyphsets))) - if locations: - # Order base master first - bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values())) - if bases: - base = next(bases) - logging.info("Base master index %s, location %s", base, locations[base]) - else: - base = 0 - logging.warning("No base master location found") - - # Form a minimum spanning tree of the locations + perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset) try: - from scipy.sparse.csgraph import minimum_spanning_tree + glyph.draw(perContourPen, outputImpliedClosingLine=True) + except TypeError: + glyph.draw(perContourPen) + self.recordings = perContourPen.value + del perContourPen - graph = [[0] * len(locations) for _ in range(len(locations))] - axes = set() - for l in locations: - axes.update(l.keys()) - axes = sorted(axes) - vectors = [tuple(l.get(k, 0) for k in axes) for l in locations] - for i, j in itertools.combinations(range(len(locations)), 2): - graph[i][j] = _vdiff_hypot2(vectors[i], vectors[j]) + for ix, contour in enumerate(self.recordings): + nodeTypes = [op for op, arg in contour.value] + self.nodeTypes.append(nodeTypes) - tree = minimum_spanning_tree(graph) - rows, cols = tree.nonzero() - graph = defaultdict(set) - for row, col in zip(rows, cols): - graph[row].add(col) - graph[col].add(row) + greenStats = StatisticsPen(glyphset=glyphset) + controlStats = StatisticsControlPen(glyphset=glyphset) + try: + contour.replay(greenStats) + contour.replay(controlStats) + self.openContours.append(False) + except OpenContourError as e: + self.openContours.append(True) + self._fill_in(ix) + continue + self.greenStats.append(greenStats) + self.controlStats.append(controlStats) + self.greenVectors.append(contour_vector_from_stats(greenStats)) + self.controlVectors.append(contour_vector_from_stats(controlStats)) - # Traverse graph from the base and assign parents - parents = [None] * len(locations) - order = [] - visited = set() - queue = deque([base]) - while queue: - i = queue.popleft() - visited.add(i) - order.append(i) - for j in sorted(graph[i]): - if j not in visited: - parents[j] = i - queue.append(j) + # Save a "normalized" version of the outlines + try: + rpen = DecomposingRecordingPen(glyphset) + tpen = TransformPen( + rpen, transform_from_stats(greenStats, inverse=True) + ) + contour.replay(tpen) + self.recordingsNormalized.append(rpen) + except ZeroDivisionError: + self.recordingsNormalized.append(None) - except ImportError: - pass + greenStats = StatisticsPen(glyphset=glyphset) + rpen.replay(greenStats) + self.greenVectorsNormalized.append(contour_vector_from_stats(greenStats)) - log.info("Parents: %s", parents) - log.info("Order: %s", order) - return parents, order + # Check starting point + if nodeTypes[0] == "addComponent": + self._fill_in(ix) + continue + + assert nodeTypes[0] == "moveTo" + assert nodeTypes[-1] in ("closePath", "endPath") + points = SimpleRecordingPointPen() + converter = SegmentToPointPen(points, False) + contour.replay(converter) + # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; + # now check all rotations and mirror-rotations of the contour and build list of isomorphic + # possible starting points. + self.points.append(points.value) + + isomorphisms = [] + self.isomorphisms.append(isomorphisms) + + # Add rotations + add_isomorphisms(points.value, isomorphisms, False) + # Add mirrored rotations + add_isomorphisms(points.value, isomorphisms, True) + + def draw(self, pen, countor_idx=None): + if countor_idx is None: + for contour in self.recordings: + contour.draw(pen) + else: + self.recordings[countor_idx].draw(pen) def test_gen( @@ -341,15 +159,14 @@ def test_gen( kinkiness *= 0.01 assert 0 <= kinkiness - if names is None: - names = glyphsets + names = names or [repr(g) for g in glyphsets] if glyphs is None: # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order # ... risks the sparse master being the first one, and only processing a subset of the glyphs glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} - parents, order = _find_parents_and_order(glyphsets, locations) + parents, order = find_parents_and_order(glyphsets, locations) def grand_parent(i, glyphname): if i is None: @@ -363,115 +180,65 @@ def test_gen( for glyph_name in glyphs: log.info("Testing glyph %s", glyph_name) - allGreenVectors = [] - allControlVectors = [] - allNodeTypes = [] - allContourIsomorphisms = [] - allContourPoints = [] - allGlyphs = [glyphset[glyph_name] for glyphset in glyphsets] + allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets] if len([1 for glyph in allGlyphs if glyph is not None]) <= 1: continue for master_idx, (glyph, glyphset, name) in enumerate( zip(allGlyphs, glyphsets, names) ): - if glyph is None: + if glyph.doesnt_exist: if not ignore_missing: - yield ( - glyph_name, - {"type": "missing", "master": name, "master_idx": master_idx}, - ) - allNodeTypes.append(None) - allControlVectors.append(None) - allGreenVectors.append(None) - allContourIsomorphisms.append(None) - allContourPoints.append(None) - continue - - perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset) - try: - glyph.draw(perContourPen, outputImpliedClosingLine=True) - except TypeError: - glyph.draw(perContourPen) - contourPens = perContourPen.value - del perContourPen - - contourControlVectors = [] - contourGreenVectors = [] - contourIsomorphisms = [] - contourPoints = [] - nodeTypes = [] - allNodeTypes.append(nodeTypes) - allControlVectors.append(contourControlVectors) - allGreenVectors.append(contourGreenVectors) - allContourIsomorphisms.append(contourIsomorphisms) - allContourPoints.append(contourPoints) - for ix, contour in enumerate(contourPens): - contourOps = tuple(op for op, arg in contour.value) - nodeTypes.append(contourOps) - - greenStats = StatisticsPen(glyphset=glyphset) - controlStats = StatisticsControlPen(glyphset=glyphset) - try: - contour.replay(greenStats) - contour.replay(controlStats) - except OpenContourError as e: yield ( glyph_name, { + "type": InterpolatableProblem.MISSING, "master": name, "master_idx": master_idx, - "contour": ix, - "type": "open_path", }, ) + continue + + has_open = False + for ix, open in enumerate(glyph.openContours): + if not open: continue - contourGreenVectors.append(_contour_vector_from_stats(greenStats)) - contourControlVectors.append(_contour_vector_from_stats(controlStats)) + has_open = True + yield ( + glyph_name, + { + "type": InterpolatableProblem.OPEN_PATH, + "master": name, + "master_idx": master_idx, + "contour": ix, + }, + ) + if has_open: + continue - # Check starting point - if contourOps[0] == "addComponent": - continue - assert contourOps[0] == "moveTo" - assert contourOps[-1] in ("closePath", "endPath") - points = SimpleRecordingPointPen() - converter = SegmentToPointPen(points, False) - contour.replay(converter) - # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve; - # now check all rotations and mirror-rotations of the contour and build list of isomorphic - # possible starting points. - - isomorphisms = [] - contourIsomorphisms.append(isomorphisms) - - # Add rotations - _add_isomorphisms(points.value, isomorphisms, False) - # Add mirrored rotations - _add_isomorphisms(points.value, isomorphisms, True) - - contourPoints.append(points.value) - - matchings = [None] * len(allControlVectors) + matchings = [None] * len(glyphsets) for m1idx in order: - if allNodeTypes[m1idx] is None: + glyph1 = allGlyphs[m1idx] + if glyph1 is None or not glyph1.nodeTypes: continue m0idx = grand_parent(m1idx, glyph_name) if m0idx is None: continue - if allNodeTypes[m0idx] is None: + glyph0 = allGlyphs[m0idx] + if glyph0 is None or not glyph0.nodeTypes: continue # # Basic compatibility checks # - m1 = allNodeTypes[m1idx] - m0 = allNodeTypes[m0idx] + m1 = glyph0.nodeTypes + m0 = glyph1.nodeTypes if len(m0) != len(m1): yield ( glyph_name, { - "type": "path_count", + "type": InterpolatableProblem.PATH_COUNT, "master_1": names[m0idx], "master_2": names[m1idx], "master_1_idx": m0idx, @@ -490,7 +257,7 @@ def test_gen( yield ( glyph_name, { - "type": "node_count", + "type": InterpolatableProblem.NODE_COUNT, "path": pathIx, "master_1": names[m0idx], "master_2": names[m1idx], @@ -506,7 +273,7 @@ def test_gen( yield ( glyph_name, { - "type": "node_incompatibility", + "type": InterpolatableProblem.NODE_INCOMPATIBILITY, "path": pathIx, "node": nodeIx, "master_1": names[m0idx], @@ -520,303 +287,186 @@ def test_gen( continue # - # "contour_order" check + # InterpolatableProblem.CONTOUR_ORDER check # - # We try matching both the StatisticsControlPen vector - # and the StatisticsPen vector. - # - # If either method found a identity matching, accept it. - # This is crucial for fonts like Kablammo[MORF].ttf and - # Nabla[EDPT,EHLT].ttf, since they really confuse the - # StatisticsPen vector because of their area=0 contours. - # - # TODO: Optimize by only computing the StatisticsPen vector - # and then checking if it is the identity vector. Only if - # not, compute the StatisticsControlPen vector and check both. - - n = len(allControlVectors[m0idx]) - done = n <= 1 - if not done: - m1Control = allControlVectors[m1idx] - m0Control = allControlVectors[m0idx] - ( - matching_control, - matching_cost_control, - identity_cost_control, - ) = _matching_for_vectors(m0Control, m1Control) - done = matching_cost_control == identity_cost_control - if not done: - m1Green = allGreenVectors[m1idx] - m0Green = allGreenVectors[m0idx] - ( - matching_green, - matching_cost_green, - identity_cost_green, - ) = _matching_for_vectors(m0Green, m1Green) - done = matching_cost_green == identity_cost_green - - if not done: - # See if reversing contours in one master helps. - # That's a common problem. Then the wrong_start_point - # test will fix them. - # - # Reverse the sign of the area (0); the rest stay the same. - if not done: - m1ControlReversed = [(-m[0],) + m[1:] for m in m1Control] - ( - matching_control_reversed, - matching_cost_control_reversed, - identity_cost_control_reversed, - ) = _matching_for_vectors(m0Control, m1ControlReversed) - done = ( - matching_cost_control_reversed == identity_cost_control_reversed - ) - if not done: - m1GreenReversed = [(-m[0],) + m[1:] for m in m1Green] - ( - matching_control_reversed, - matching_cost_control_reversed, - identity_cost_control_reversed, - ) = _matching_for_vectors(m0Control, m1ControlReversed) - done = ( - matching_cost_control_reversed == identity_cost_control_reversed - ) - - if not done: - # Otherwise, use the worst of the two matchings. - if ( - matching_cost_control / identity_cost_control - < matching_cost_green / identity_cost_green - ): - matching = matching_control - matching_cost = matching_cost_control - identity_cost = identity_cost_control - else: - matching = matching_green - matching_cost = matching_cost_green - identity_cost = identity_cost_green - - if matching_cost < identity_cost * tolerance: - # print(matching_cost_control / identity_cost_control, matching_cost_green / identity_cost_green) - - yield ( - glyph_name, - { - "type": "contour_order", - "master_1": names[m0idx], - "master_2": names[m1idx], - "master_1_idx": m0idx, - "master_2_idx": m1idx, - "value_1": list(range(n)), - "value_2": matching, - "tolerance": matching_cost / identity_cost, - }, - ) - matchings[m1idx] = matching + this_tolerance, matching = test_contour_order(glyph0, glyph1) + if this_tolerance < tolerance: + yield ( + glyph_name, + { + "type": InterpolatableProblem.CONTOUR_ORDER, + "master_1": names[m0idx], + "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, + "value_1": list(range(len(matching))), + "value_2": matching, + "tolerance": this_tolerance, + }, + ) + matchings[m1idx] = matching # - # "wrong_start_point" check + # wrong-start-point / weight check # - m1 = allContourIsomorphisms[m1idx] - m0 = allContourIsomorphisms[m0idx] + m0Isomorphisms = glyph0.isomorphisms + m1Isomorphisms = glyph1.isomorphisms + m0Vectors = glyph0.greenVectors + m1Vectors = glyph1.greenVectors + m0VectorsNormalized = glyph0.greenVectorsNormalized + m1VectorsNormalized = glyph1.greenVectorsNormalized + recording0 = glyph0.recordings + recording1 = glyph1.recordings + recording0Normalized = glyph0.recordingsNormalized + recording1Normalized = glyph1.recordingsNormalized # If contour-order is wrong, adjust it - if matchings[m1idx] is not None and m1: # m1 is empty for composite glyphs - m1 = [m1[i] for i in matchings[m1idx]] + matching = matchings[m1idx] + if ( + matching is not None and m1Isomorphisms + ): # m1 is empty for composite glyphs + m1Isomorphisms = [m1Isomorphisms[i] for i in matching] + m1Vectors = [m1Vectors[i] for i in matching] + m1VectorsNormalized = [m1VectorsNormalized[i] for i in matching] + recording1 = [recording1[i] for i in matching] + recording1Normalized = [recording1Normalized[i] for i in matching] - for ix, (contour0, contour1) in enumerate(zip(m0, m1)): - if len(contour0) == 0 or len(contour0) != len(contour1): + midRecording = [] + for c0, c1 in zip(recording0, recording1): + try: + r = RecordingPen() + r.value = list(lerpRecordings(c0.value, c1.value)) + midRecording.append(r) + except ValueError: + # Mismatch because of the reordering above + midRecording.append(None) + + for ix, (contour0, contour1) in enumerate( + zip(m0Isomorphisms, m1Isomorphisms) + ): + if ( + contour0 is None + or contour1 is None + or len(contour0) == 0 + or len(contour0) != len(contour1) + ): # We already reported this; or nothing to do; or not compatible # after reordering above. continue - c0 = contour0[0] - # Next few lines duplicated below. - costs = [_vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1] - min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1]) - first_cost = costs[0] + this_tolerance, proposed_point, reverse = test_starting_point( + glyph0, glyph1, ix, tolerance, matching + ) - if min_cost < first_cost * tolerance: - this_tolerance = min_cost / first_cost - # c0 is the first isomorphism of the m0 master - # contour1 is list of all isomorphisms of the m1 master - # - # If the two shapes are both circle-ish and slightly - # rotated, we detect wrong start point. This is for - # example the case hundreds of times in - # RobotoSerif-Italic[GRAD,opsz,wdth,wght].ttf - # - # If the proposed point is only one off from the first - # point (and not reversed), try harder: - # - # Find the major eigenvector of the covariance matrix, - # and rotate the contours by that angle. Then find the - # closest point again. If it matches this time, let it - # pass. + if this_tolerance < tolerance: + yield ( + glyph_name, + { + "type": InterpolatableProblem.WRONG_START_POINT, + "contour": ix, + "master_1": names[m0idx], + "master_2": names[m1idx], + "master_1_idx": m0idx, + "master_2_idx": m1idx, + "value_1": 0, + "value_2": proposed_point, + "reversed": reverse, + "tolerance": this_tolerance, + }, + ) - proposed_point = contour1[min_cost_idx][1] - reverse = contour1[min_cost_idx][2] - num_points = len(allContourPoints[m1idx][ix]) - leeway = 3 - okay = False - if not reverse and ( - proposed_point <= leeway - or proposed_point >= num_points - leeway + # Weight check. + # + # If contour could be mid-interpolated, and the two + # contours have the same area sign, proceeed. + # + # The sign difference can happen if it's a weirdo + # self-intersecting contour; ignore it. + contour = midRecording[ix] + + normalized = False + if contour and (m0Vectors[ix][0] < 0) == (m1Vectors[ix][0] < 0): + if normalized: + midStats = StatisticsPen(glyphset=None) + tpen = TransformPen( + midStats, transform_from_stats(midStats, inverse=True) + ) + contour.replay(tpen) + else: + midStats = StatisticsPen(glyphset=None) + contour.replay(midStats) + + midVector = contour_vector_from_stats(midStats) + + m0Vec = m0Vectors[ix] if not normalized else m0VectorsNormalized[ix] + m1Vec = m1Vectors[ix] if not normalized else m1VectorsNormalized[ix] + size0 = m0Vec[0] * m0Vec[0] + size1 = m1Vec[0] * m1Vec[0] + midSize = midVector[0] * midVector[0] + + power = 1 + t = tolerance**power + + for overweight, problem_type in enumerate( + ( + InterpolatableProblem.UNDERWEIGHT, + InterpolatableProblem.OVERWEIGHT, + ) ): - # Try harder - - m0Vectors = allGreenVectors[m0idx][ix] - m1Vectors = allGreenVectors[m1idx][ix] - - # Recover the covariance matrix from the GreenVectors. - # This is a 2x2 matrix. - transforms = [] - for vector in (m0Vectors, m1Vectors): - meanX = vector[1] - meanY = vector[2] - stddevX = vector[3] / 2 - stddevY = vector[4] / 2 - correlation = vector[5] / abs(vector[0]) - - # https://cookierobotics.com/007/ - a = stddevX * stddevX # VarianceX - c = stddevY * stddevY # VarianceY - b = correlation * stddevX * stddevY # Covariance - - delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5 - lambda1 = (a + c) * 0.5 + delta # Major eigenvalue - lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue - theta = ( - atan2(lambda1 - a, b) - if b != 0 - else (pi * 0.5 if a < c else 0) - ) - trans = Transform() - # Don't translate here. We are working on the complex-vector - # that includes more than just the points. It's horrible what - # we are doing anyway... - # trans = trans.translate(meanX, meanY) - trans = trans.rotate(theta) - trans = trans.scale(sqrt(lambda1), sqrt(lambda2)) - transforms.append(trans) - - trans = transforms[0] - new_c0 = ( - [ - complex(*trans.transformPoint((pt.real, pt.imag))) - for pt in c0[0] - ], - ) + c0[1:] - trans = transforms[1] - new_contour1 = [] - for c1 in contour1: - new_c1 = ( - [ - complex(*trans.transformPoint((pt.real, pt.imag))) - for pt in c1[0] - ], - ) + c1[1:] - new_contour1.append(new_c1) - - # Next few lines duplicate from above. - costs = [ - _vdiff_hypot2_complex(new_c0[0], new_c1[0]) - for new_c1 in new_contour1 - ] - min_cost_idx, min_cost = min( - enumerate(costs), key=lambda x: x[1] - ) - first_cost = costs[0] - if min_cost < first_cost * tolerance: - pass - # this_tolerance = min_cost / first_cost - # proposed_point = new_contour1[min_cost_idx][1] + if overweight: + expectedSize = sqrt(size0 * size1) + expectedSize = (size0 + size1) - expectedSize + expectedSize = size1 + (midSize - size1) + continue else: - okay = True + expectedSize = sqrt(size0 * size1) - if not okay: - # Adjust contour points for further operations. - points = allContourPoints[m1idx][ix] - # Rotate them - points = points[proposed_point:] + points[:proposed_point] - if reverse: - points = points[::-1] - allContourPoints[m1idx][ix] = points - yield ( - glyph_name, - { - "type": "wrong_start_point", - "contour": ix, - "master_1": names[m0idx], - "master_2": names[m1idx], - "master_1_idx": m0idx, - "master_2_idx": m1idx, - "value_1": 0, - "value_2": proposed_point, - "reversed": reverse, - "tolerance": this_tolerance, - }, + log.debug( + "%s: actual size %g; threshold size %g, master sizes: %g, %g", + problem_type, + midSize, + expectedSize, + size0, + size1, ) - else: - # If first_cost is Too Largeā„¢, do further inspection. - # This can happen specially in the case of TrueType - # fonts, where the original contour had wrong start point, - # but because of the cubic->quadratic conversion, we don't - # have many isomorphisms to work with. - # The threshold here is all black magic. It's just here to - # speed things up so we don't end up doing a full matching - # on every contour that is correct. - threshold = ( - len(c0[0]) * (allControlVectors[m0idx][ix][0] * 0.5) ** 2 / 4 - ) # Magic only - c1 = contour1[min_cost_idx] + size0, size1 = sorted((size0, size1)) - # If point counts are different it's because of the contour - # reordering above. We can in theory still try, but our - # bipartite-matching implementations currently assume - # equal number of vertices on both sides. I'm lazy to update - # all three different implementations! - - if len(c0[0]) == len(c1[0]) and first_cost > threshold: - # Do a quick(!) matching between the points. If it's way off, - # flag it. This can happen specially in the case of TrueType - # fonts, where the original contour had wrong start point, but - # because of the cubic->quadratic conversion, we don't have many - # isomorphisms. - points0 = c0[0][::_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR] - points1 = c1[0][::_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR] - - graph = [ - [_hypot2_complex(p0 - p1) for p1 in points1] - for p0 in points0 - ] - matching, matching_cost = min_cost_perfect_bipartite_matching( - graph - ) - identity_cost = sum(graph[i][i] for i in range(len(graph))) - - if matching_cost < identity_cost / 5: # Heuristic - # print(matching_cost, identity_cost, matching) + if ( + not overweight and expectedSize * tolerance > midSize + 1e-5 + ) or (overweight and 1e-5 + expectedSize / tolerance < midSize): + try: + if overweight: + this_tolerance = (expectedSize / midSize) ** ( + 1 / power + ) + else: + this_tolerance = (midSize / expectedSize) ** ( + 1 / power + ) + except ZeroDivisionError: + this_tolerance = 0 + log.debug("tolerance %g", this_tolerance) yield ( glyph_name, { - "type": "wrong_structure", + "type": problem_type, "contour": ix, "master_1": names[m0idx], "master_2": names[m1idx], "master_1_idx": m0idx, "master_2_idx": m1idx, + "tolerance": this_tolerance, }, ) # # "kink" detector # - m1 = allContourPoints[m1idx] - m0 = allContourPoints[m0idx] + m0 = glyph0.points + m1 = glyph1.points # If contour-order is wrong, adjust it if matchings[m1idx] is not None and m1: # m1 is empty for composite glyphs @@ -828,7 +478,12 @@ def test_gen( ) for ix, (contour0, contour1) in enumerate(zip(m0, m1)): - if len(contour0) == 0 or len(contour0) != len(contour1): + if ( + contour0 is None + or contour1 is None + or len(contour0) == 0 + or len(contour0) != len(contour1) + ): # We already reported this; or nothing to do; or not compatible # after reordering above. continue @@ -926,11 +581,18 @@ def test_gen( this_tolerance = t / (abs(sin_mid) * kinkiness) - # print(deviation, deviation_ratio, sin_mid, r_diff, this_tolerance) + log.debug( + "kink: deviation %g; deviation_ratio %g; sin_mid %g; r_diff %g", + deviation, + deviation_ratio, + sin_mid, + r_diff, + ) + log.debug("tolerance %g", this_tolerance) yield ( glyph_name, { - "type": "kink", + "type": InterpolatableProblem.KINK, "contour": ix, "master_1": names[m0idx], "master_2": names[m1idx], @@ -949,7 +611,7 @@ def test_gen( yield ( glyph_name, { - "type": "nothing", + "type": InterpolatableProblem.NOTHING, "master_1": names[m0idx], "master_2": names[m1idx], "master_1_idx": m0idx, @@ -1016,6 +678,11 @@ def main(args=None): action="store", help="Output report in PDF format", ) + parser.add_argument( + "--ps", + action="store", + help="Output report in PostScript format", + ) parser.add_argument( "--html", action="store", @@ -1051,12 +718,15 @@ def main(args=None): help="Name of the master to use in the report. If not provided, all are used.", ) parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.") + parser.add_argument("--debug", action="store_true", help="Run with debug output.") args = parser.parse_args(args) from fontTools import configLogger configLogger(level=("INFO" if args.verbose else "ERROR")) + if args.debug: + configLogger(level="DEBUG") glyphs = args.glyphs.split() if args.glyphs else None @@ -1290,16 +960,16 @@ def main(args=None): print(f" Masters: %s:" % ", ".join(master_names), file=f) last_master_idxs = master_idxs - if p["type"] == "missing": + if p["type"] == InterpolatableProblem.MISSING: print( " Glyph was missing in master %s" % p["master"], file=f ) - elif p["type"] == "open_path": + elif p["type"] == InterpolatableProblem.OPEN_PATH: print( " Glyph has an open path in master %s" % p["master"], file=f, ) - elif p["type"] == "path_count": + elif p["type"] == InterpolatableProblem.PATH_COUNT: print( " Path count differs: %i in %s, %i in %s" % ( @@ -1310,7 +980,7 @@ def main(args=None): ), file=f, ) - elif p["type"] == "node_count": + elif p["type"] == InterpolatableProblem.NODE_COUNT: print( " Node count differs in path %i: %i in %s, %i in %s" % ( @@ -1322,7 +992,7 @@ def main(args=None): ), file=f, ) - elif p["type"] == "node_incompatibility": + elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY: print( " Node %o incompatible in path %i: %s in %s, %s in %s" % ( @@ -1335,7 +1005,7 @@ def main(args=None): ), file=f, ) - elif p["type"] == "contour_order": + elif p["type"] == InterpolatableProblem.CONTOUR_ORDER: print( " Contour order differs: %s in %s, %s in %s" % ( @@ -1346,7 +1016,7 @@ def main(args=None): ), file=f, ) - elif p["type"] == "wrong_start_point": + elif p["type"] == InterpolatableProblem.WRONG_START_POINT: print( " Contour %d start point differs: %s in %s, %s in %s; reversed: %s" % ( @@ -1359,9 +1029,9 @@ def main(args=None): ), file=f, ) - elif p["type"] == "wrong_structure": + elif p["type"] == InterpolatableProblem.UNDERWEIGHT: print( - " Contour %d structures differ: %s, %s" + " Contour %d interpolation is underweight: %s, %s" % ( p["contour"], p["master_1"], @@ -1369,7 +1039,17 @@ def main(args=None): ), file=f, ) - elif p["type"] == "kink": + elif p["type"] == InterpolatableProblem.OVERWEIGHT: + print( + " Contour %d interpolation is overweight: %s, %s" + % ( + p["contour"], + p["master_1"], + p["master_2"], + ), + file=f, + ) + elif p["type"] == InterpolatableProblem.KINK: print( " Contour %d has a kink at %s: %s, %s" % ( @@ -1380,7 +1060,7 @@ def main(args=None): ), file=f, ) - elif p["type"] == "nothing": + elif p["type"] == InterpolatableProblem.NOTHING: print( " Showing %s and %s" % ( @@ -1393,6 +1073,8 @@ def main(args=None): for glyphname, problem in problems_gen: problems[glyphname].append(problem) + problems = sort_problems(problems) + if args.pdf: log.info("Writing PDF to %s", args.pdf) from .interpolatablePlot import InterpolatablePDF @@ -1405,6 +1087,18 @@ def main(args=None): if not problems and not args.quiet: pdf.draw_cupcake() + if args.ps: + log.info("Writing PS to %s", args.pdf) + from .interpolatablePlot import InterpolatablePS + + with InterpolatablePS(args.ps, glyphsets=glyphsets, names=names) as ps: + ps.add_title_page( + original_args_inputs, tolerance=tolerance, kinkiness=kinkiness + ) + ps.add_problems(problems) + if not problems and not args.quiet: + ps.draw_cupcake() + if args.html: log.info("Writing HTML to %s", args.html) from .interpolatablePlot import InterpolatableSVG diff --git a/Lib/fontTools/varLib/interpolatableHelpers.py b/Lib/fontTools/varLib/interpolatableHelpers.py new file mode 100644 index 000000000..2a3540fff --- /dev/null +++ b/Lib/fontTools/varLib/interpolatableHelpers.py @@ -0,0 +1,380 @@ +from fontTools.ttLib.ttGlyphSet import LerpGlyphSet +from fontTools.pens.basePen import AbstractPen, BasePen, DecomposingPen +from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen +from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen +from fontTools.misc.transform import Transform +from collections import defaultdict, deque +from math import sqrt, copysign, atan2, pi +from enum import Enum +import itertools + +import logging + +log = logging.getLogger("fontTools.varLib.interpolatable") + + +class InterpolatableProblem: + NOTHING = "nothing" + MISSING = "missing" + OPEN_PATH = "open_path" + PATH_COUNT = "path_count" + NODE_COUNT = "node_count" + NODE_INCOMPATIBILITY = "node_incompatibility" + CONTOUR_ORDER = "contour_order" + WRONG_START_POINT = "wrong_start_point" + KINK = "kink" + UNDERWEIGHT = "underweight" + OVERWEIGHT = "overweight" + + severity = { + MISSING: 1, + OPEN_PATH: 2, + PATH_COUNT: 3, + NODE_COUNT: 4, + NODE_INCOMPATIBILITY: 5, + CONTOUR_ORDER: 6, + WRONG_START_POINT: 7, + KINK: 8, + UNDERWEIGHT: 9, + OVERWEIGHT: 10, + NOTHING: 11, + } + + +def sort_problems(problems): + """Sort problems by severity, then by glyph name, then by problem message.""" + return dict( + sorted( + problems.items(), + key=lambda _: -min( + ( + (InterpolatableProblem.severity[p["type"]] + p.get("tolerance", 0)) + for p in _[1] + ), + ), + reverse=True, + ) + ) + + +def rot_list(l, k): + """Rotate list by k items forward. Ie. item at position 0 will be + at position k in returned list. Negative k is allowed.""" + return l[-k:] + l[:-k] + + +class PerContourPen(BasePen): + def __init__(self, Pen, glyphset=None): + BasePen.__init__(self, glyphset) + self._glyphset = glyphset + self._Pen = Pen + self._pen = None + self.value = [] + + def _moveTo(self, p0): + self._newItem() + self._pen.moveTo(p0) + + def _lineTo(self, p1): + self._pen.lineTo(p1) + + def _qCurveToOne(self, p1, p2): + self._pen.qCurveTo(p1, p2) + + def _curveToOne(self, p1, p2, p3): + self._pen.curveTo(p1, p2, p3) + + def _closePath(self): + self._pen.closePath() + self._pen = None + + def _endPath(self): + self._pen.endPath() + self._pen = None + + def _newItem(self): + self._pen = pen = self._Pen() + self.value.append(pen) + + +class PerContourOrComponentPen(PerContourPen): + def addComponent(self, glyphName, transformation): + self._newItem() + self.value[-1].addComponent(glyphName, transformation) + + +class SimpleRecordingPointPen(AbstractPointPen): + def __init__(self): + self.value = [] + + def beginPath(self, identifier=None, **kwargs): + pass + + def endPath(self) -> None: + pass + + def addPoint(self, pt, segmentType=None): + self.value.append((pt, False if segmentType is None else True)) + + +def vdiff_hypot2(v0, v1): + s = 0 + for x0, x1 in zip(v0, v1): + d = x1 - x0 + s += d * d + return s + + +def vdiff_hypot2_complex(v0, v1): + s = 0 + for x0, x1 in zip(v0, v1): + d = x1 - x0 + s += d.real * d.real + d.imag * d.imag + # This does the same but seems to be slower: + # s += (d * d.conjugate()).real + return s + + +def matching_cost(G, matching): + return sum(G[i][j] for i, j in enumerate(matching)) + + +def min_cost_perfect_bipartite_matching_scipy(G): + n = len(G) + rows, cols = linear_sum_assignment(G) + assert (rows == list(range(n))).all() + return list(cols), matching_cost(G, cols) + + +def min_cost_perfect_bipartite_matching_munkres(G): + n = len(G) + cols = [None] * n + for row, col in Munkres().compute(G): + cols[row] = col + return cols, matching_cost(G, cols) + + +def min_cost_perfect_bipartite_matching_bruteforce(G): + n = len(G) + + if n > 6: + raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'") + + # Otherwise just brute-force + permutations = itertools.permutations(range(n)) + best = list(next(permutations)) + best_cost = matching_cost(G, best) + for p in permutations: + cost = matching_cost(G, p) + if cost < best_cost: + best, best_cost = list(p), cost + return best, best_cost + + +try: + from scipy.optimize import linear_sum_assignment + + min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy +except ImportError: + try: + from munkres import Munkres + + min_cost_perfect_bipartite_matching = ( + min_cost_perfect_bipartite_matching_munkres + ) + except ImportError: + min_cost_perfect_bipartite_matching = ( + min_cost_perfect_bipartite_matching_bruteforce + ) + + +def contour_vector_from_stats(stats): + # Don't change the order of items here. + # It's okay to add to the end, but otherwise, other + # code depends on it. Search for "covariance". + size = sqrt(abs(stats.area)) + return ( + copysign((size), stats.area), + stats.meanX, + stats.meanY, + stats.stddevX * 2, + stats.stddevY * 2, + stats.correlation * size, + ) + + +def matching_for_vectors(m0, m1): + n = len(m0) + + identity_matching = list(range(n)) + + costs = [[vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0] + ( + matching, + matching_cost, + ) = min_cost_perfect_bipartite_matching(costs) + identity_cost = sum(costs[i][i] for i in range(n)) + return matching, matching_cost, identity_cost + + +def points_characteristic_bits(points): + bits = 0 + for pt, b in reversed(points): + bits = (bits << 1) | b + return bits + + +_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4 + + +def points_complex_vector(points): + vector = [] + if not points: + return vector + points = [complex(*pt) for pt, _ in points] + n = len(points) + assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4 + points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1]) + while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR: + points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1]) + for i in range(n): + # The weights are magic numbers. + + # The point itself + p0 = points[i] + vector.append(p0) + + # The vector to the next point + p1 = points[i + 1] + d0 = p1 - p0 + vector.append(d0 * 3) + + # The turn vector + p2 = points[i + 2] + d1 = p2 - p1 + vector.append(d1 - d0) + + # The angle to the next point, as a cross product; + # Square root of, to match dimentionality of distance. + cross = d0.real * d1.imag - d0.imag * d1.real + cross = copysign(sqrt(abs(cross)), cross) + vector.append(cross * 4) + + return vector + + +def add_isomorphisms(points, isomorphisms, reverse): + reference_bits = points_characteristic_bits(points) + n = len(points) + + # if points[0][0] == points[-1][0]: + # abort + + if reverse: + points = points[::-1] + bits = points_characteristic_bits(points) + else: + bits = reference_bits + + vector = points_complex_vector(points) + + assert len(vector) % n == 0 + mult = len(vector) // n + mask = (1 << n) - 1 + + for i in range(n): + b = ((bits << (n - i)) & mask) | (bits >> i) + if b == reference_bits: + isomorphisms.append( + (rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse) + ) + + +def find_parents_and_order(glyphsets, locations): + parents = [None] + list(range(len(glyphsets) - 1)) + order = list(range(len(glyphsets))) + if locations: + # Order base master first + bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values())) + if bases: + base = next(bases) + logging.info("Base master index %s, location %s", base, locations[base]) + else: + base = 0 + logging.warning("No base master location found") + + # Form a minimum spanning tree of the locations + try: + from scipy.sparse.csgraph import minimum_spanning_tree + + graph = [[0] * len(locations) for _ in range(len(locations))] + axes = set() + for l in locations: + axes.update(l.keys()) + axes = sorted(axes) + vectors = [tuple(l.get(k, 0) for k in axes) for l in locations] + for i, j in itertools.combinations(range(len(locations)), 2): + graph[i][j] = vdiff_hypot2(vectors[i], vectors[j]) + + tree = minimum_spanning_tree(graph) + rows, cols = tree.nonzero() + graph = defaultdict(set) + for row, col in zip(rows, cols): + graph[row].add(col) + graph[col].add(row) + + # Traverse graph from the base and assign parents + parents = [None] * len(locations) + order = [] + visited = set() + queue = deque([base]) + while queue: + i = queue.popleft() + visited.add(i) + order.append(i) + for j in sorted(graph[i]): + if j not in visited: + parents[j] = i + queue.append(j) + + except ImportError: + pass + + log.info("Parents: %s", parents) + log.info("Order: %s", order) + return parents, order + + +def transform_from_stats(stats, inverse=False): + # https://cookierobotics.com/007/ + a = stats.varianceX + b = stats.covariance + c = stats.varianceY + + delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5 + lambda1 = (a + c) * 0.5 + delta # Major eigenvalue + lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue + theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0) + trans = Transform() + + if lambda2 < 0: + # XXX This is a hack. + # The problem is that the covariance matrix is singular. + # This happens when the contour is a line, or a circle. + # In that case, the covariance matrix is not a good + # representation of the contour. + # We should probably detect this earlier and avoid + # computing the covariance matrix in the first place. + # But for now, we just avoid the division by zero. + lambda2 = 0 + + if inverse: + trans = trans.translate(-stats.meanX, -stats.meanY) + trans = trans.rotate(-theta) + trans = trans.scale(1 / sqrt(lambda1), 1 / sqrt(lambda2)) + else: + trans = trans.scale(sqrt(lambda1), sqrt(lambda2)) + trans = trans.rotate(theta) + trans = trans.translate(stats.meanX, stats.meanY) + + return trans diff --git a/Lib/fontTools/varLib/interpolatablePlot.py b/Lib/fontTools/varLib/interpolatablePlot.py index a094fa9b4..4c8b5e2f6 100644 --- a/Lib/fontTools/varLib/interpolatablePlot.py +++ b/Lib/fontTools/varLib/interpolatablePlot.py @@ -1,4 +1,6 @@ +from .interpolatableHelpers import * from fontTools.ttLib import TTFont +from fontTools.ttLib.ttGlyphSet import LerpGlyphSet from fontTools.pens.recordingPen import ( RecordingPen, DecomposingRecordingPen, @@ -11,7 +13,7 @@ from fontTools.pens.pointPen import ( PointToSegmentPen, ReverseContourPointPen, ) -from fontTools.varLib.interpolatable import ( +from fontTools.varLib.interpolatableHelpers import ( PerContourOrComponentPen, SimpleRecordingPointPen, ) @@ -26,38 +28,6 @@ import logging log = logging.getLogger("fontTools.varLib.interpolatable") -class LerpGlyphSet: - def __init__(self, glyphset1, glyphset2, factor=0.5): - self.glyphset1 = glyphset1 - self.glyphset2 = glyphset2 - self.factor = factor - - def __getitem__(self, glyphname): - return LerpGlyph(glyphname, self) - - -class LerpGlyph: - def __init__(self, glyphname, glyphset): - self.glyphset = glyphset - self.glyphname = glyphname - - def draw(self, pen): - recording1 = DecomposingRecordingPen(self.glyphset.glyphset1) - self.glyphset.glyphset1[self.glyphname].draw(recording1) - recording2 = DecomposingRecordingPen(self.glyphset.glyphset2) - self.glyphset.glyphset2[self.glyphname].draw(recording2) - - factor = self.glyphset.factor - for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value): - if op1 != op2: - raise ValueError("Mismatching operations: %s, %s" % (op1, op2)) - mid_args = [ - (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) - for (x1, y1), (x2, y2) in zip(args1, args2) - ] - getattr(pen, op1)(*mid_args) - - class OverridingDict(dict): def __init__(self, parent_dict): self.parent_dict = parent_dict @@ -79,24 +49,25 @@ class InterpolatablePlot: fill_color = (0.8, 0.8, 0.8) stroke_color = (0.1, 0.1, 0.1) stroke_width = 2 - oncurve_node_color = (0, 0.8, 0) + oncurve_node_color = (0, 0.8, 0, 0.7) oncurve_node_diameter = 10 - offcurve_node_color = (0, 0.5, 0) + offcurve_node_color = (0, 0.5, 0, 0.7) offcurve_node_diameter = 8 - handle_color = (0.2, 1, 0.2) + handle_color = (0, 0.5, 0, 0.7) handle_width = 1 - corrected_start_point_color = (0, 0.9, 0) + corrected_start_point_color = (0, 0.9, 0, 0.7) corrected_start_point_size = 15 - wrong_start_point_color = (1, 0, 0) - start_point_color = (0, 0, 1) + wrong_start_point_color = (1, 0, 0, 0.7) + start_point_color = (0, 0, 1, 0.7) start_arrow_length = 20 kink_point_size = 10 kink_point_color = (1, 0, 1, 0.7) kink_circle_size = 25 kink_circle_stroke_width = 1.5 - kink_circle_color = (1, 0, 1, 0.5) + kink_circle_color = (1, 0, 1, 0.7) contour_colors = ((1, 0, 0), (0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 1), (0, 1, 1)) contour_alpha = 0.5 + weight_issue_contour_color = (0, 0, 0, 0.4) no_issues_label = "Your font's good! Have a cupcake..." no_issues_label_color = (0, 0.5, 0) cupcake_color = (0.3, 0, 0.3) @@ -125,8 +96,19 @@ class InterpolatablePlot: \\\\ |||| |||| |||| // |||||||||||||||||||||||| """ - shrug_color = (0, 0.3, 0.3) + emoticon_color = (0, 0.3, 0.3) shrug = r"""\_(")_/""" + underweight = r""" + o +/|\ +/ \ +""" + overweight = r""" + o +/O\ +/ \ +""" + yay = r""" \o/ """ def __init__(self, out, glyphsets, names=None, **kwargs): self.out = out @@ -242,15 +224,29 @@ class InterpolatablePlot: ) y -= self.pad + self.line_height + self.draw_label("Underweight contours", x=xxx, y=y, width=width) + cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.line_height) + cr.set_source_rgb(*self.fill_color) + cr.fill_preserve() + if self.stroke_color: + cr.set_source_rgb(*self.stroke_color) + cr.set_line_width(self.stroke_width) + cr.stroke_preserve() + cr.set_source_rgba(*self.weight_issue_contour_color) + cr.fill() + y -= self.pad + self.line_height + self.draw_label( "Colored contours: contours with the wrong order", x=xxx, y=y, width=width ) cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.line_height) - cr.set_source_rgb(*self.fill_color) - cr.fill_preserve() - cr.set_source_rgb(*self.stroke_color) - cr.set_line_width(self.stroke_width) - cr.stroke_preserve() + if self.fill_color: + cr.set_source_rgb(*self.fill_color) + cr.fill_preserve() + if self.stroke_color: + cr.set_source_rgb(*self.stroke_color) + cr.set_line_width(self.stroke_width) + cr.stroke_preserve() cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha) cr.fill() y -= self.pad + self.line_height @@ -402,7 +398,7 @@ class InterpolatablePlot: ) master_indices = [problems[0][k] for k in master_keys] - if problem_type == "missing": + if problem_type == InterpolatableProblem.MISSING: sample_glyph = next( i for i, m in enumerate(self.glyphsets) if m[glyphname] is not None ) @@ -456,17 +452,18 @@ class InterpolatablePlot: self.draw_glyph(glyphset, glyphname, problems, which, x=x, y=y) ) else: - self.draw_shrug(x=x, y=y) + self.draw_emoticon(self.shrug, x=x, y=y) y += self.height + self.pad if any( pt in ( - "nothing", - "wrong_start_point", - "contour_order", - "wrong_structure", - "kink", + InterpolatableProblem.NOTHING, + InterpolatableProblem.WRONG_START_POINT, + InterpolatableProblem.CONTOUR_ORDER, + InterpolatableProblem.KINK, + InterpolatableProblem.UNDERWEIGHT, + InterpolatableProblem.OVERWEIGHT, ) for pt in problem_types ): @@ -489,7 +486,17 @@ class InterpolatablePlot: self.draw_glyph( midway_glyphset, glyphname, - [{"type": "midway"}] + [p for p in problems if p["type"] == "kink"], + [{"type": "midway"}] + + [ + p + for p in problems + if p["type"] + in ( + InterpolatableProblem.KINK, + InterpolatableProblem.UNDERWEIGHT, + InterpolatableProblem.OVERWEIGHT, + ) + ], None, x=x, y=y, @@ -498,171 +505,187 @@ class InterpolatablePlot: y += self.height + self.pad + if any( + pt + in ( + InterpolatableProblem.WRONG_START_POINT, + InterpolatableProblem.CONTOUR_ORDER, + InterpolatableProblem.KINK, + ) + for pt in problem_types + ): # Draw the proposed fix self.draw_label("proposed fix", x=x, y=y, color=self.head_color, align=0.5) y += self.line_height + self.pad - if problem_type in ("wrong_structure"): - self.draw_shrug(x=x, y=y) - else: - overriding1 = OverridingDict(glyphset1) - overriding2 = OverridingDict(glyphset2) - perContourPen1 = PerContourOrComponentPen( - RecordingPen, glyphset=overriding1 - ) - perContourPen2 = PerContourOrComponentPen( - RecordingPen, glyphset=overriding2 - ) - glyphset1[glyphname].draw(perContourPen1) - glyphset2[glyphname].draw(perContourPen2) + overriding1 = OverridingDict(glyphset1) + overriding2 = OverridingDict(glyphset2) + perContourPen1 = PerContourOrComponentPen( + RecordingPen, glyphset=overriding1 + ) + perContourPen2 = PerContourOrComponentPen( + RecordingPen, glyphset=overriding2 + ) + glyphset1[glyphname].draw(perContourPen1) + glyphset2[glyphname].draw(perContourPen2) - for problem in problems: - if problem["type"] == "contour_order": - fixed_contours = [ - perContourPen2.value[i] for i in problems[0]["value_2"] - ] - perContourPen2.value = fixed_contours + for problem in problems: + if problem["type"] == InterpolatableProblem.CONTOUR_ORDER: + fixed_contours = [ + perContourPen2.value[i] for i in problems[0]["value_2"] + ] + perContourPen2.value = fixed_contours - for problem in problems: - if problem["type"] == "wrong_start_point": - # Save the wrong contours - wrongContour1 = perContourPen1.value[problem["contour"]] - wrongContour2 = perContourPen2.value[problem["contour"]] + for problem in problems: + if problem["type"] == InterpolatableProblem.WRONG_START_POINT: + # Save the wrong contours + wrongContour1 = perContourPen1.value[problem["contour"]] + wrongContour2 = perContourPen2.value[problem["contour"]] - # Convert the wrong contours to point pens - points1 = RecordingPointPen() - converter = SegmentToPointPen(points1, False) - wrongContour1.replay(converter) - points2 = RecordingPointPen() - converter = SegmentToPointPen(points2, False) - wrongContour2.replay(converter) + # Convert the wrong contours to point pens + points1 = RecordingPointPen() + converter = SegmentToPointPen(points1, False) + wrongContour1.replay(converter) + points2 = RecordingPointPen() + converter = SegmentToPointPen(points2, False) + wrongContour2.replay(converter) - proposed_start = problem["value_2"] + proposed_start = problem["value_2"] - # See if we need reversing; fragile but worth a try - if problem["reversed"]: - new_points2 = RecordingPointPen() - reversedPen = ReverseContourPointPen(new_points2) - points2.replay(reversedPen) - points2 = new_points2 - proposed_start = len(points2.value) - 2 - proposed_start + # See if we need reversing; fragile but worth a try + if problem["reversed"]: + new_points2 = RecordingPointPen() + reversedPen = ReverseContourPointPen(new_points2) + points2.replay(reversedPen) + points2 = new_points2 + proposed_start = len(points2.value) - 2 - proposed_start - # Rotate points2 so that the first point is the same as in points1 - beginPath = points2.value[:1] - endPath = points2.value[-1:] - pts = points2.value[1:-1] - pts = pts[proposed_start:] + pts[:proposed_start] - points2.value = beginPath + pts + endPath + # Rotate points2 so that the first point is the same as in points1 + beginPath = points2.value[:1] + endPath = points2.value[-1:] + pts = points2.value[1:-1] + pts = pts[proposed_start:] + pts[:proposed_start] + points2.value = beginPath + pts + endPath - # Convert the point pens back to segment pens - segment1 = RecordingPen() - converter = PointToSegmentPen(segment1, True) - points1.replay(converter) - segment2 = RecordingPen() - converter = PointToSegmentPen(segment2, True) - points2.replay(converter) + # Convert the point pens back to segment pens + segment1 = RecordingPen() + converter = PointToSegmentPen(segment1, True) + points1.replay(converter) + segment2 = RecordingPen() + converter = PointToSegmentPen(segment2, True) + points2.replay(converter) - # Replace the wrong contours - wrongContour1.value = segment1.value - wrongContour2.value = segment2.value - perContourPen1.value[problem["contour"]] = wrongContour1 - perContourPen2.value[problem["contour"]] = wrongContour2 + # Replace the wrong contours + wrongContour1.value = segment1.value + wrongContour2.value = segment2.value + perContourPen1.value[problem["contour"]] = wrongContour1 + perContourPen2.value[problem["contour"]] = wrongContour2 - for problem in problems: - # If we have a kink, try to fix it. - if problem["type"] == "kink": - # Save the wrong contours - wrongContour1 = perContourPen1.value[problem["contour"]] - wrongContour2 = perContourPen2.value[problem["contour"]] + for problem in problems: + # If we have a kink, try to fix it. + if problem["type"] == InterpolatableProblem.KINK: + # Save the wrong contours + wrongContour1 = perContourPen1.value[problem["contour"]] + wrongContour2 = perContourPen2.value[problem["contour"]] - # Convert the wrong contours to point pens - points1 = RecordingPointPen() - converter = SegmentToPointPen(points1, False) - wrongContour1.replay(converter) - points2 = RecordingPointPen() - converter = SegmentToPointPen(points2, False) - wrongContour2.replay(converter) + # Convert the wrong contours to point pens + points1 = RecordingPointPen() + converter = SegmentToPointPen(points1, False) + wrongContour1.replay(converter) + points2 = RecordingPointPen() + converter = SegmentToPointPen(points2, False) + wrongContour2.replay(converter) - i = problem["value"] + i = problem["value"] - # Position points to be around the same ratio - # beginPath / endPath dance - j = i + 1 - pt0 = points1.value[j][1][0] - pt1 = points2.value[j][1][0] - j_prev = (i - 1) % (len(points1.value) - 2) + 1 - pt0_prev = points1.value[j_prev][1][0] - pt1_prev = points2.value[j_prev][1][0] - j_next = (i + 1) % (len(points1.value) - 2) + 1 - pt0_next = points1.value[j_next][1][0] - pt1_next = points2.value[j_next][1][0] + # Position points to be around the same ratio + # beginPath / endPath dance + j = i + 1 + pt0 = points1.value[j][1][0] + pt1 = points2.value[j][1][0] + j_prev = (i - 1) % (len(points1.value) - 2) + 1 + pt0_prev = points1.value[j_prev][1][0] + pt1_prev = points2.value[j_prev][1][0] + j_next = (i + 1) % (len(points1.value) - 2) + 1 + pt0_next = points1.value[j_next][1][0] + pt1_next = points2.value[j_next][1][0] - pt0 = complex(*pt0) - pt1 = complex(*pt1) - pt0_prev = complex(*pt0_prev) - pt1_prev = complex(*pt1_prev) - pt0_next = complex(*pt0_next) - pt1_next = complex(*pt1_next) + pt0 = complex(*pt0) + pt1 = complex(*pt1) + pt0_prev = complex(*pt0_prev) + pt1_prev = complex(*pt1_prev) + pt0_next = complex(*pt0_next) + pt1_next = complex(*pt1_next) - # Find the ratio of the distance between the points - r0 = abs(pt0 - pt0_prev) / abs(pt0_next - pt0_prev) - r1 = abs(pt1 - pt1_prev) / abs(pt1_next - pt1_prev) - r_mid = (r0 + r1) / 2 + # Find the ratio of the distance between the points + r0 = abs(pt0 - pt0_prev) / abs(pt0_next - pt0_prev) + r1 = abs(pt1 - pt1_prev) / abs(pt1_next - pt1_prev) + r_mid = (r0 + r1) / 2 - pt0 = pt0_prev + r_mid * (pt0_next - pt0_prev) - pt1 = pt1_prev + r_mid * (pt1_next - pt1_prev) + pt0 = pt0_prev + r_mid * (pt0_next - pt0_prev) + pt1 = pt1_prev + r_mid * (pt1_next - pt1_prev) - points1.value[j] = ( - points1.value[j][0], - (((pt0.real, pt0.imag),) + points1.value[j][1][1:]), - points1.value[j][2], - ) - points2.value[j] = ( - points2.value[j][0], - (((pt1.real, pt1.imag),) + points2.value[j][1][1:]), - points2.value[j][2], - ) - - # Convert the point pens back to segment pens - segment1 = RecordingPen() - converter = PointToSegmentPen(segment1, True) - points1.replay(converter) - segment2 = RecordingPen() - converter = PointToSegmentPen(segment2, True) - points2.replay(converter) - - # Replace the wrong contours - wrongContour1.value = segment1.value - wrongContour2.value = segment2.value - - # Assemble - fixed1 = RecordingPen() - fixed2 = RecordingPen() - for contour in perContourPen1.value: - fixed1.value.extend(contour.value) - for contour in perContourPen2.value: - fixed2.value.extend(contour.value) - fixed1.draw = fixed1.replay - fixed2.draw = fixed2.replay - - overriding1[glyphname] = fixed1 - overriding2[glyphname] = fixed2 - - try: - midway_glyphset = LerpGlyphSet(overriding1, overriding2) - self.draw_glyph( - midway_glyphset, - glyphname, - {"type": "fixed"}, - None, - x=x, - y=y, - scale=min(scales), + points1.value[j] = ( + points1.value[j][0], + (((pt0.real, pt0.imag),) + points1.value[j][1][1:]), + points1.value[j][2], ) - except ValueError: - self.draw_shrug(x=x, y=y) - y += self.height + self.pad + points2.value[j] = ( + points2.value[j][0], + (((pt1.real, pt1.imag),) + points2.value[j][1][1:]), + points2.value[j][2], + ) + + # Convert the point pens back to segment pens + segment1 = RecordingPen() + converter = PointToSegmentPen(segment1, True) + points1.replay(converter) + segment2 = RecordingPen() + converter = PointToSegmentPen(segment2, True) + points2.replay(converter) + + # Replace the wrong contours + wrongContour1.value = segment1.value + wrongContour2.value = segment2.value + + # Assemble + fixed1 = RecordingPen() + fixed2 = RecordingPen() + for contour in perContourPen1.value: + fixed1.value.extend(contour.value) + for contour in perContourPen2.value: + fixed2.value.extend(contour.value) + fixed1.draw = fixed1.replay + fixed2.draw = fixed2.replay + + overriding1[glyphname] = fixed1 + overriding2[glyphname] = fixed2 + + try: + midway_glyphset = LerpGlyphSet(overriding1, overriding2) + self.draw_glyph( + midway_glyphset, + glyphname, + {"type": "fixed"}, + None, + x=x, + y=y, + scale=min(scales), + ) + except ValueError: + self.draw_emoticon(self.shrug, x=x, y=y) + y += self.height + self.pad + + else: + emoticon = self.shrug + if InterpolatableProblem.UNDERWEIGHT in problem_types: + emoticon = self.underweight + elif InterpolatableProblem.OVERWEIGHT in problem_types: + emoticon = self.overweight + elif InterpolatableProblem.NOTHING in problem_types: + emoticon = self.yay + self.draw_emoticon(emoticon, x=x, y=y) if show_page_number: self.draw_label( @@ -776,7 +799,7 @@ class InterpolatablePlot: pen = CairoPen(glyphset, cr) decomposedRecording.replay(pen) - if self.fill_color and problem_type != "open_path": + if self.fill_color and problem_type != InterpolatableProblem.OPEN_PATH: cr.set_source_rgb(*self.fill_color) cr.fill_preserve() @@ -787,13 +810,28 @@ class InterpolatablePlot: cr.new_path() + if ( + InterpolatableProblem.UNDERWEIGHT in problem_types + or InterpolatableProblem.OVERWEIGHT in problem_types + ): + perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset) + recording.replay(perContourPen) + for problem in problems: + if problem["type"] in ( + InterpolatableProblem.UNDERWEIGHT, + InterpolatableProblem.OVERWEIGHT, + ): + contour = perContourPen.value[problem["contour"]] + contour.replay(CairoPen(glyphset, cr)) + cr.set_source_rgba(*self.weight_issue_contour_color) + cr.fill() + if any( t in problem_types for t in { - "nothing", - "node_count", - "node_incompatibility", - "wrong_structure", + InterpolatableProblem.NOTHING, + InterpolatableProblem.NODE_COUNT, + InterpolatableProblem.NODE_INCOMPATIBILITY, } ): cr.set_line_cap(cairo.LINE_CAP_ROUND) @@ -805,7 +843,7 @@ class InterpolatablePlot: x, y = args[-1] cr.move_to(x, y) cr.line_to(x, y) - cr.set_source_rgb(*self.oncurve_node_color) + cr.set_source_rgba(*self.oncurve_node_color) cr.set_line_width(self.oncurve_node_diameter / scale) cr.stroke() @@ -816,7 +854,7 @@ class InterpolatablePlot: for x, y in args[:-1]: cr.move_to(x, y) cr.line_to(x, y) - cr.set_source_rgb(*self.offcurve_node_color) + cr.set_source_rgba(*self.offcurve_node_color) cr.set_line_width(self.offcurve_node_diameter / scale) cr.stroke() @@ -841,13 +879,13 @@ class InterpolatablePlot: else: continue - cr.set_source_rgb(*self.handle_color) + cr.set_source_rgba(*self.handle_color) cr.set_line_width(self.handle_width / scale) cr.stroke() matching = None for problem in problems: - if problem["type"] == "contour_order": + if problem["type"] == InterpolatableProblem.CONTOUR_ORDER: matching = problem["value_2"] colors = cycle(self.contour_colors) perContourPen = PerContourOrComponentPen( @@ -863,7 +901,10 @@ class InterpolatablePlot: cr.fill() for problem in problems: - if problem["type"] in ("nothing", "wrong_start_point", "wrong_structure"): + if problem["type"] in ( + InterpolatableProblem.NOTHING, + InterpolatableProblem.WRONG_START_POINT, + ): idx = problem.get("contour") # Draw suggested point @@ -902,7 +943,10 @@ class InterpolatablePlot: continue if first_pt is None: continue - second_pt = args[0] + if segment == "closePath": + second_pt = first_pt + else: + second_pt = args[0] if idx is None or i == idx: cr.save() @@ -938,7 +982,7 @@ class InterpolatablePlot: cr.restore() - if problem["type"] == "kink": + if problem["type"] == InterpolatableProblem.KINK: idx = problem.get("contour") perContourPen = PerContourOrComponentPen( RecordingPen, glyphset=glyphset @@ -950,22 +994,6 @@ class InterpolatablePlot: converter ) - if which == 1 or midway: - wrong_start_point_problem = [ - pt - for pt in problems - if pt["type"] == "wrong_start_point" - and pt.get("contour") == idx - ] - if wrong_start_point_problem: - proposed_start = wrong_start_point_problem[0]["value_2"] - points.value = ( - points.value[proposed_start:] - + points.value[:proposed_start] - ) - if wrong_start_point_problem[0]["reversed"]: - points.value = points.value[::-1] - targetPoint = points.value[problem["value"]][0] cr.save() cr.translate(*targetPoint) @@ -1031,6 +1059,44 @@ class InterpolatablePlot: cr.fill() cr.restore() + def draw_text(self, text, *, x=0, y=0, color=(0, 0, 0), width=None, height=None): + if width is None: + width = self.width + if height is None: + height = self.height + + text = text.splitlines() + cr = cairo.Context(self.surface) + cr.set_source_rgb(*color) + cr.set_font_size(self.line_height) + cr.select_font_face( + "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL + ) + text_width = 0 + text_height = 0 + font_extents = cr.font_extents() + font_line_height = font_extents[2] + font_ascent = font_extents[0] + for line in text: + extents = cr.text_extents(line) + text_width = max(text_width, extents.x_advance) + text_height += font_line_height + if not text_width: + return + cr.translate(x, y) + scale = min(width / text_width, height / text_height) + # center + cr.translate( + (width - text_width * scale) / 2, (height - text_height * scale) / 2 + ) + cr.scale(scale, scale) + + cr.translate(0, font_ascent) + for line in text: + cr.move_to(0, 0) + cr.show_text(line) + cr.translate(0, font_line_height) + def draw_cupcake(self): self.set_size(self.total_width(), self.total_height()) @@ -1044,50 +1110,17 @@ class InterpolatablePlot: bold=True, ) - cupcake = self.cupcake.splitlines() - cr = cairo.Context(self.surface) - cr.set_source_rgb(*self.cupcake_color) - cr.set_font_size(self.line_height) - cr.select_font_face( - "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL + self.draw_text( + self.cupcake, + x=self.pad, + y=self.pad + self.line_height, + width=self.total_width() - 2 * self.pad, + height=self.total_height() - 2 * self.pad - self.line_height, + color=self.cupcake_color, ) - width = 0 - height = 0 - font_extents = cr.font_extents() - font_line_height = font_extents[2] - font_ascent = font_extents[0] - for line in cupcake: - extents = cr.text_extents(line) - width = max(width, extents.width) - height += font_line_height - if not width: - return - cr.scale( - (self.total_width() - 2 * self.pad) / width, - (self.total_height() - 2 * self.pad - self.line_height) / height, - ) - cr.translate(self.pad, self.pad + font_ascent + self.line_height) - for line in cupcake: - cr.move_to(0, 0) - cr.show_text(line) - cr.translate(0, font_line_height) - def draw_shrug(self, x=0, y=0): - cr = cairo.Context(self.surface) - cr.translate(x, y) - cr.set_source_rgb(*self.shrug_color) - cr.set_font_size(self.line_height) - cr.select_font_face( - "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL - ) - extents = cr.text_extents(self.shrug) - if not extents.width: - return - cr.translate(0, self.height * 0.6) - scale = self.width / extents.width - cr.scale(scale, scale) - cr.move_to(-extents.x_bearing, 0) - cr.show_text(self.shrug) + def draw_emoticon(self, emoticon, x=0, y=0): + self.draw_text(emoticon, x=x, y=y, color=self.emoticon_color) class InterpolatablePostscriptLike(InterpolatablePlot): @@ -1105,10 +1138,6 @@ class InterpolatablePostscriptLike(InterpolatablePlot): super().show_page() self.surface.show_page() - def __enter__(self): - self.surface = cairo.PSSurface(self.out, self.width, self.height) - return self - class InterpolatablePS(InterpolatablePostscriptLike): def __enter__(self): diff --git a/Lib/fontTools/varLib/interpolatableTestContourOrder.py b/Lib/fontTools/varLib/interpolatableTestContourOrder.py new file mode 100644 index 000000000..9edb1afcb --- /dev/null +++ b/Lib/fontTools/varLib/interpolatableTestContourOrder.py @@ -0,0 +1,82 @@ +from .interpolatableHelpers import * +import logging + +log = logging.getLogger("fontTools.varLib.interpolatable") + + +def test_contour_order(glyph0, glyph1): + # We try matching both the StatisticsControlPen vector + # and the StatisticsPen vector. + # + # If either method found a identity matching, accept it. + # This is crucial for fonts like Kablammo[MORF].ttf and + # Nabla[EDPT,EHLT].ttf, since they really confuse the + # StatisticsPen vector because of their area=0 contours. + + n = len(glyph0.controlVectors) + matching = None + matching_cost = 0 + identity_cost = 0 + done = n <= 1 + if not done: + m0Control = glyph0.controlVectors + m1Control = glyph1.controlVectors + ( + matching_control, + matching_cost_control, + identity_cost_control, + ) = matching_for_vectors(m0Control, m1Control) + done = matching_cost_control == identity_cost_control + if not done: + m0Green = glyph0.greenVectors + m1Green = glyph1.greenVectors + ( + matching_green, + matching_cost_green, + identity_cost_green, + ) = matching_for_vectors(m0Green, m1Green) + done = matching_cost_green == identity_cost_green + + if not done: + # See if reversing contours in one master helps. + # That's a common problem. Then the wrong_start_point + # test will fix them. + # + # Reverse the sign of the area (0); the rest stay the same. + if not done: + m1ControlReversed = [(-m[0],) + m[1:] for m in m1Control] + ( + matching_control_reversed, + matching_cost_control_reversed, + identity_cost_control_reversed, + ) = matching_for_vectors(m0Control, m1ControlReversed) + done = matching_cost_control_reversed == identity_cost_control_reversed + if not done: + m1GreenReversed = [(-m[0],) + m[1:] for m in m1Green] + ( + matching_control_reversed, + matching_cost_control_reversed, + identity_cost_control_reversed, + ) = matching_for_vectors(m0Control, m1ControlReversed) + done = matching_cost_control_reversed == identity_cost_control_reversed + + if not done: + # Otherwise, use the worst of the two matchings. + if ( + matching_cost_control / identity_cost_control + < matching_cost_green / identity_cost_green + ): + matching = matching_control + matching_cost = matching_cost_control + identity_cost = identity_cost_control + else: + matching = matching_green + matching_cost = matching_cost_green + identity_cost = identity_cost_green + + this_tolerance = matching_cost / identity_cost if identity_cost else 1 + log.debug( + "test-contour-order: tolerance %g", + this_tolerance, + ) + return this_tolerance, matching diff --git a/Lib/fontTools/varLib/interpolatableTestStartingPoint.py b/Lib/fontTools/varLib/interpolatableTestStartingPoint.py new file mode 100644 index 000000000..e76000663 --- /dev/null +++ b/Lib/fontTools/varLib/interpolatableTestStartingPoint.py @@ -0,0 +1,105 @@ +from .interpolatableHelpers import * + + +def test_starting_point(glyph0, glyph1, ix, tolerance, matching): + if matching is None: + matching = list(range(len(glyph0.isomorphisms))) + contour0 = glyph0.isomorphisms[ix] + contour1 = glyph1.isomorphisms[matching[ix]] + m0Vectors = glyph0.greenVectors + m1Vectors = [glyph1.greenVectors[i] for i in matching] + + c0 = contour0[0] + # Next few lines duplicated below. + costs = [vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1] + min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1]) + first_cost = costs[0] + proposed_point = contour1[min_cost_idx][1] + reverse = contour1[min_cost_idx][2] + + if min_cost < first_cost * tolerance: + # c0 is the first isomorphism of the m0 master + # contour1 is list of all isomorphisms of the m1 master + # + # If the two shapes are both circle-ish and slightly + # rotated, we detect wrong start point. This is for + # example the case hundreds of times in + # RobotoSerif-Italic[GRAD,opsz,wdth,wght].ttf + # + # If the proposed point is only one off from the first + # point (and not reversed), try harder: + # + # Find the major eigenvector of the covariance matrix, + # and rotate the contours by that angle. Then find the + # closest point again. If it matches this time, let it + # pass. + + num_points = len(glyph1.points[ix]) + leeway = 3 + if not reverse and ( + proposed_point <= leeway or proposed_point >= num_points - leeway + ): + # Try harder + + # Recover the covariance matrix from the GreenVectors. + # This is a 2x2 matrix. + transforms = [] + for vector in (m0Vectors[ix], m1Vectors[ix]): + meanX = vector[1] + meanY = vector[2] + stddevX = vector[3] * 0.5 + stddevY = vector[4] * 0.5 + correlation = vector[5] / abs(vector[0]) + + # https://cookierobotics.com/007/ + a = stddevX * stddevX # VarianceX + c = stddevY * stddevY # VarianceY + b = correlation * stddevX * stddevY # Covariance + + delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5 + lambda1 = (a + c) * 0.5 + delta # Major eigenvalue + lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue + theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0) + trans = Transform() + # Don't translate here. We are working on the complex-vector + # that includes more than just the points. It's horrible what + # we are doing anyway... + # trans = trans.translate(meanX, meanY) + trans = trans.rotate(theta) + trans = trans.scale(sqrt(lambda1), sqrt(lambda2)) + transforms.append(trans) + + trans = transforms[0] + new_c0 = ( + [complex(*trans.transformPoint((pt.real, pt.imag))) for pt in c0[0]], + ) + c0[1:] + trans = transforms[1] + new_contour1 = [] + for c1 in contour1: + new_c1 = ( + [ + complex(*trans.transformPoint((pt.real, pt.imag))) + for pt in c1[0] + ], + ) + c1[1:] + new_contour1.append(new_c1) + + # Next few lines duplicate from above. + costs = [ + vdiff_hypot2_complex(new_c0[0], new_c1[0]) for new_c1 in new_contour1 + ] + min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1]) + first_cost = costs[0] + if min_cost < first_cost * tolerance: + # Don't report this + # min_cost = first_cost + # reverse = False + # proposed_point = 0 # new_contour1[min_cost_idx][1] + pass + + this_tolerance = min_cost / first_cost if first_cost else 1 + log.debug( + "test-starting-point: tolerance %g", + this_tolerance, + ) + return this_tolerance, proposed_point, reverse diff --git a/NEWS.rst b/NEWS.rst index ddbf2df28..775bb5e85 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,19 @@ +4.46.0 (released 2023-12-02) +---------------------------- + +- [featureVars] Allow to register the same set of substitution rules to multiple features. + The ``addFeatureVariations`` function can now take a list of featureTags; similarly, the + lib key 'com.github.fonttools.varLib.featureVarsFeatureTag' can now take a + comma-separateed string of feature tags (e.g. "salt,ss01") instead of a single tag (#3360). +- [featureVars] Don't overwrite GSUB FeatureVariations, but append new records to it + for features which are not already there. But raise ``VarLibError`` if the feature tag + already has feature variations associated with it (#3363). +- [varLib] Added ``addGSUBFeatureVariations`` function to add GSUB Feature Variations + to an existing variable font from rules defined in a DesignSpace document (#3362). +- [varLib.interpolatable] Various bugfixes and rendering improvements. In particular, + a new test for "underweight" glyphs. The new test reports quite a few false-positives + though. Please send feedback. + 4.45.1 (released 2023-11-23) ---------------------------- diff --git a/Tests/misc/bezierTools_test.py b/Tests/misc/bezierTools_test.py index 8a3e2ecda..ce8a9e17e 100644 --- a/Tests/misc/bezierTools_test.py +++ b/Tests/misc/bezierTools_test.py @@ -4,6 +4,7 @@ from fontTools.misc.bezierTools import ( calcQuadraticArcLength, calcCubicBounds, curveLineIntersections, + curveCurveIntersections, segmentPointAtT, splitLine, splitQuadratic, @@ -189,3 +190,10 @@ def test_calcQuadraticArcLength(): assert calcQuadraticArcLength( (210, 333), (289, 333), (326.5, 290.5) ) == pytest.approx(127.9225) + + +def test_intersections_linelike(): + seg1 = [(0.0, 0.0), (0.0, 0.25), (0.0, 0.75), (0.0, 1.0)] + seg2 = [(0.0, 0.5), (0.25, 0.5), (0.75, 0.5), (1.0, 0.5)] + pt = curveCurveIntersections(seg1, seg2)[0][0] + assert pt == (0.0, 0.5) diff --git a/Tests/ttLib/ttGlyphSet_test.py b/Tests/ttLib/ttGlyphSet_test.py index 56514464b..177b8a4e7 100644 --- a/Tests/ttLib/ttGlyphSet_test.py +++ b/Tests/ttLib/ttGlyphSet_test.py @@ -1,5 +1,6 @@ from fontTools.ttLib import TTFont from fontTools.ttLib import ttGlyphSet +from fontTools.ttLib.ttGlyphSet import LerpGlyphSet from fontTools.pens.recordingPen import ( RecordingPen, RecordingPointPen, @@ -164,6 +165,53 @@ class TTGlyphSetTest(object): assert actual == expected, (location, actual, expected) + @pytest.mark.parametrize( + "fontfile, locations, factor, expected", + [ + ( + "I.ttf", + ({"wght": 400}, {"wght": 1000}), + 0.5, + [ + ("moveTo", ((151.5, 0.0),)), + ("lineTo", ((458.5, 0.0),)), + ("lineTo", ((458.5, 1456.0),)), + ("lineTo", ((151.5, 1456.0),)), + ("closePath", ()), + ], + ), + ( + "I.ttf", + ({"wght": 400}, {"wght": 1000}), + 0.25, + [ + ("moveTo", ((163.25, 0.0),)), + ("lineTo", ((412.75, 0.0),)), + ("lineTo", ((412.75, 1456.0),)), + ("lineTo", ((163.25, 1456.0),)), + ("closePath", ()), + ], + ), + ], + ) + def test_lerp_glyphset(self, fontfile, locations, factor, expected): + font = TTFont(self.getpath(fontfile)) + glyphset1 = font.getGlyphSet(location=locations[0]) + glyphset2 = font.getGlyphSet(location=locations[1]) + glyphset = LerpGlyphSet(glyphset1, glyphset2, factor) + + assert "I" in glyphset + + pen = RecordingPen() + glyph = glyphset["I"] + + assert glyphset.get("foobar") is None + + glyph.draw(pen) + actual = pen.value + + assert actual == expected, (locations, actual, expected) + def test_glyphset_varComposite_components(self): font = TTFont(self.getpath("varc-ac00-ac01.ttf")) glyphset = font.getGlyphSet() diff --git a/Tests/varLib/data/FeatureVarsCustomTag.designspace b/Tests/varLib/data/FeatureVarsCustomTag.designspace index 45b06f30c..ef24ccfdf 100644 --- a/Tests/varLib/data/FeatureVarsCustomTag.designspace +++ b/Tests/varLib/data/FeatureVarsCustomTag.designspace @@ -71,7 +71,7 @@ com.github.fonttools.varLib.featureVarsFeatureTag - calt + rclt,calt diff --git a/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx b/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx index 3f9e1e080..5ad62a98b 100644 --- a/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx +++ b/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx @@ -33,21 +33,28 @@ - + + + + + + + @@ -95,7 +102,7 @@ - + @@ -104,6 +111,14 @@ + + + + + + + + @@ -122,7 +137,7 @@ - + @@ -130,6 +145,13 @@ + + + + + + + @@ -143,7 +165,7 @@ - + @@ -151,6 +173,13 @@ + + + + + + + @@ -164,7 +193,7 @@ - + @@ -172,6 +201,13 @@ + + + + + + + diff --git a/Tests/varLib/featureVars_test.py b/Tests/varLib/featureVars_test.py index 7a3a6650d..99f41e776 100644 --- a/Tests/varLib/featureVars_test.py +++ b/Tests/varLib/featureVars_test.py @@ -1,4 +1,136 @@ -from fontTools.varLib.featureVars import overlayFeatureVariations, overlayBox +from collections import OrderedDict +from fontTools.designspaceLib import AxisDescriptor +from fontTools.ttLib import TTFont, newTable +from fontTools import varLib +from fontTools.varLib.featureVars import ( + addFeatureVariations, + overlayFeatureVariations, + overlayBox, +) +import pytest + + +def makeVariableFont(glyphOrder, axes): + font = TTFont() + font.setGlyphOrder(glyphOrder) + font["name"] = newTable("name") + ds_axes = OrderedDict() + for axisTag, (minimum, default, maximum) in axes.items(): + axis = AxisDescriptor() + axis.name = axis.tag = axis.labelNames["en"] = axisTag + axis.minimum, axis.default, axis.maximum = minimum, default, maximum + ds_axes[axisTag] = axis + varLib._add_fvar(font, ds_axes, instances=()) + return font + + +@pytest.fixture +def varfont(): + return makeVariableFont( + [".notdef", "space", "A", "B", "A.alt", "B.alt"], + {"wght": (100, 400, 900)}, + ) + + +def test_addFeatureVariations(varfont): + assert "GSUB" not in varfont + + addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})]) + + assert "GSUB" in varfont + gsub = varfont["GSUB"].table + + assert len(gsub.ScriptList.ScriptRecord) == 1 + assert gsub.ScriptList.ScriptRecord[0].ScriptTag == "DFLT" + + assert len(gsub.FeatureList.FeatureRecord) == 1 + assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn" + + assert len(gsub.LookupList.Lookup) == 1 + assert gsub.LookupList.Lookup[0].LookupType == 1 + assert len(gsub.LookupList.Lookup[0].SubTable) == 1 + assert gsub.LookupList.Lookup[0].SubTable[0].mapping == {"A": "A.alt"} + + assert gsub.FeatureVariations is not None + assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1 + fvr = gsub.FeatureVariations.FeatureVariationRecord[0] + assert len(fvr.ConditionSet.ConditionTable) == 1 + cst = fvr.ConditionSet.ConditionTable[0] + assert cst.AxisIndex == 0 + assert cst.FilterRangeMinValue == 0.5 + assert cst.FilterRangeMaxValue == 1.0 + assert len(fvr.FeatureTableSubstitution.SubstitutionRecord) == 1 + ftsr = fvr.FeatureTableSubstitution.SubstitutionRecord[0] + assert ftsr.FeatureIndex == 0 + assert ftsr.Feature.LookupListIndex == [0] + + +def _substitution_features(gsub, rec_index): + fea_tags = [feature.FeatureTag for feature in gsub.FeatureList.FeatureRecord] + fea_indices = [ + gsub.FeatureVariations.FeatureVariationRecord[rec_index] + .FeatureTableSubstitution.SubstitutionRecord[i] + .FeatureIndex + for i in range( + len( + gsub.FeatureVariations.FeatureVariationRecord[ + rec_index + ].FeatureTableSubstitution.SubstitutionRecord + ) + ) + ] + return [(i, fea_tags[i]) for i in fea_indices] + + +def test_addFeatureVariations_existing_variable_feature(varfont): + assert "GSUB" not in varfont + + addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})]) + + gsub = varfont["GSUB"].table + assert len(gsub.FeatureList.FeatureRecord) == 1 + assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn" + assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1 + assert _substitution_features(gsub, rec_index=0) == [(0, "rvrn")] + + # can't add feature variations for an existing feature tag that already has some, + # in this case the default 'rvrn' + with pytest.raises( + varLib.VarLibError, + match=r"FeatureVariations already exist for feature tag\(s\): {'rvrn'}", + ): + addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})]) + + +def test_addFeatureVariations_new_feature(varfont): + assert "GSUB" not in varfont + + addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})]) + + gsub = varfont["GSUB"].table + assert len(gsub.FeatureList.FeatureRecord) == 1 + assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn" + assert len(gsub.LookupList.Lookup) == 1 + assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1 + assert _substitution_features(gsub, rec_index=0) == [(0, "rvrn")] + + # we can add feature variations for a feature tag that does not have + # any feature variations yet + addFeatureVariations( + varfont, [([{"wght": (-1.0, 0.0)}], {"B": "B.alt"})], featureTag="rclt" + ) + + assert len(gsub.FeatureList.FeatureRecord) == 2 + # Note 'rclt' is now first (index=0) in the feature list sorted by tag, and + # 'rvrn' is second (index=1) + assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rclt" + assert gsub.FeatureList.FeatureRecord[1].FeatureTag == "rvrn" + assert len(gsub.LookupList.Lookup) == 2 + assert len(gsub.FeatureVariations.FeatureVariationRecord) == 2 + # The new 'rclt' feature variation record is appended to the end; + # the feature index for 'rvrn' feature table substitution record is now 1 + assert _substitution_features(gsub, rec_index=0) == [(1, "rvrn")] + assert _substitution_features(gsub, rec_index=1) == [(0, "rclt")] def _test_linear(n): diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py index da6dd9ee0..0ace29f71 100644 --- a/Tests/varLib/instancer/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -1986,7 +1986,10 @@ class LimitTupleVariationAxisRangesTest: TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), "wght", 0.6, - [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])], + [ + TupleVariation({"wght": (0.0, 0.833334, 1.0)}, [100, 100]), + TupleVariation({"wght": (0.833334, 1.0, 1.0)}, [80, 80]), + ], ), ( TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), @@ -2001,7 +2004,10 @@ class LimitTupleVariationAxisRangesTest: TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), "wght", 0.5, - [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])], + [ + TupleVariation({"wght": (0.0, 0.4, 1)}, [100, 100]), + TupleVariation({"wght": (0.4, 1, 1)}, [62.5, 62.5]), + ], ), ( TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]), @@ -2065,7 +2071,10 @@ class LimitTupleVariationAxisRangesTest: TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), "wght", -0.6, - [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])], + [ + TupleVariation({"wght": (-1.0, -0.833334, 0.0)}, [100, 100]), + TupleVariation({"wght": (-1.0, -1.0, -0.833334)}, [80, 80]), + ], ), ( TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), @@ -2080,7 +2089,10 @@ class LimitTupleVariationAxisRangesTest: TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), "wght", -0.5, - [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])], + [ + TupleVariation({"wght": (-1.0, -0.4, 0.0)}, [100, 100]), + TupleVariation({"wght": (-1.0, -1.0, -0.4)}, [62.5, 62.5]), + ], ), ( TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]), diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py index b9acf82f8..7bcab637f 100644 --- a/Tests/varLib/instancer/solver_test.py +++ b/Tests/varLib/instancer/solver_test.py @@ -43,7 +43,8 @@ class RebaseTentTest(object): (0, 0.2, 1), (-1, 0, 0.8), [ - (1, (0, 0.25, 1.25)), + (1, (0, 0.25, 1)), + (0.25, (0.25, 1, 1)), ], ), # Case 3 boundary @@ -51,7 +52,8 @@ class RebaseTentTest(object): (0, 0.4, 1), (-1, 0, 0.5), [ - (1, (0, 0.8, 1.99994)), + (1, (0, 0.8, 1)), + (2.5 / 3, (0.8, 1, 1)), ], ), # Case 4 @@ -234,7 +236,8 @@ class RebaseTentTest(object): (0, 0.2, 1), (0, 0, 0.5), [ - (1, (0, 0.4, 1.99994)), + (1, (0, 0.4, 1)), + (0.625, (0.4, 1, 1)), ], ), # https://github.com/fonttools/fonttools/issues/3139 diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py index 87616ae2e..53acc1653 100644 --- a/Tests/varLib/varLib_test.py +++ b/Tests/varLib/varLib_test.py @@ -1,7 +1,13 @@ from fontTools.colorLib.builder import buildCOLR from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables import otTables as ot -from fontTools.varLib import build, build_many, load_designspace, _add_COLR +from fontTools.varLib import ( + build, + build_many, + load_designspace, + _add_COLR, + addGSUBFeatureVariations, +) from fontTools.varLib.errors import VarLibValidationError import fontTools.varLib.errors as varLibErrors from fontTools.varLib.models import VariationModel @@ -1009,6 +1015,32 @@ Expected to see .ScriptCount==1, instead saw 0""", save_before_dump=True, ) + def test_varlib_addGSUBFeatureVariations(self): + ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") + + ds = DesignSpaceDocument.fromfile( + self.get_test_input("FeatureVars.designspace") + ) + for source in ds.sources: + ttx_dump = TTFont() + ttx_dump.importXML( + os.path.join( + ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx") + ) + ) + source.font = ttx_dump + + varfont, _, _ = build(ds, exclude=["GSUB"]) + assert "GSUB" not in varfont + + addGSUBFeatureVariations(varfont, ds) + assert "GSUB" in varfont + + tables = ["fvar", "GSUB"] + expected_ttx_path = self.get_test_output("FeatureVars.ttx") + self.expect_ttx(varfont, expected_ttx_path, tables) + self.check_ttx_dump(varfont, expected_ttx_path, tables, ".ttf") + def test_load_masters_layerName_without_required_font(): ds = DesignSpaceDocument() diff --git a/dev-requirements.txt b/dev-requirements.txt index 69601f35c..c9cc23aa1 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -5,4 +5,4 @@ sphinx>=1.5.5 mypy>=0.782 # Pin black as each version could change formatting, breaking CI randomly. -black==23.10.0 +black==23.11.0 diff --git a/requirements.txt b/requirements.txt index 68a356006..f85e8138e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ brotli==1.1.0; platform_python_implementation != "PyPy" brotlicffi==1.1.0.0; platform_python_implementation == "PyPy" unicodedata2==15.1.0; python_version <= '3.11' scipy==1.10.0; platform_python_implementation != "PyPy" and python_version <= '3.8' # pyup: ignore -scipy==1.11.3; platform_python_implementation != "PyPy" and python_version >= '3.9' +scipy==1.11.4; platform_python_implementation != "PyPy" and python_version >= '3.9' munkres==1.1.4; platform_python_implementation == "PyPy" zopfli==0.2.3 fs==2.4.16 @@ -15,6 +15,6 @@ ufo2ft==2.33.4 pyobjc==10.0; sys_platform == "darwin" freetype-py==2.4.0 uharfbuzz==0.37.3 -glyphsLib==6.4.1 # this is only required to run Tests/varLib/interpolatable_test.py +glyphsLib==6.6.0 # this is only required to run Tests/varLib/interpolatable_test.py lxml==4.9.3 sympy==1.12 diff --git a/setup.cfg b/setup.cfg index f38671aa9..9b898a1ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.45.2.dev0 +current_version = 4.46.1.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 8f01dc74d..10e639de3 100755 --- a/setup.py +++ b/setup.py @@ -241,7 +241,7 @@ class release(Command): ] changelog_name = "NEWS.rst" - version_RE = re.compile("^[0-9]+\.[0-9]+") + version_RE = re.compile(r"^[0-9]+\.[0-9]+") date_fmt = "%Y-%m-%d" header_fmt = "%s (released %s)" commit_message = "Release {new_version}" @@ -467,7 +467,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.45.2.dev0", + version="4.46.1.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com",