diff --git a/Lib/fontmake/font_project.py b/Lib/fontmake/font_project.py index c8d0fd09..e5402aee 100644 --- a/Lib/fontmake/font_project.py +++ b/Lib/fontmake/font_project.py @@ -14,6 +14,7 @@ from __future__ import annotations +import dataclasses import enum import glob import logging @@ -26,7 +27,6 @@ from pathlib import Path from re import fullmatch -import attr import ufo2ft import ufo2ft.errors import ufoLib2 @@ -37,13 +37,12 @@ from fontTools.misc.plistlib import load as readPlist from fontTools.ttLib import TTFont from fontTools.varLib.interpolate_layout import interpolate_layout -from ufo2ft import CFFOptimization +from ufo2ft import CFFOptimization, instantiator from ufo2ft.featureCompiler import parseLayoutFeatures from ufo2ft.featureWriters import FEATURE_WRITERS_KEY, loadFeatureWriters from ufo2ft.filters import FILTERS_KEY, loadFilters from ufo2ft.util import makeOfficialGlyphOrder -from fontmake import instantiator from fontmake.compatibility import CompatibilityChecker from fontmake.errors import FontmakeError, TTFAError from fontmake.ttfautohint import ttfautohint @@ -1021,7 +1020,7 @@ def interpolate_instance_ufos( fea_txt = parseLayoutFeatures( subDoc.default.font, includeDir=fea_include_dir ).asFea() - generator = attr.evolve(generator, copy_feature_text=fea_txt) + generator = dataclasses.replace(generator, copy_feature_text=fea_txt) for instance in subDoc.instances: # Skip instances that have been set to non-export in Glyphs, stored as the diff --git a/Lib/fontmake/instantiator.py b/Lib/fontmake/instantiator.py index 50da4b86..a2e24fe2 100644 --- a/Lib/fontmake/instantiator.py +++ b/Lib/fontmake/instantiator.py @@ -1,810 +1,6 @@ -# This code is based on ufoProcessor code, which is licensed as follows: -# Copyright (c) 2017-2018 LettError and Erik van Blokland -# All rights reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +"""fontmake.instantiator has been moved to ufo2ft.instantiator. -"""Module for generating static font instances. - -It is an alternative to mutatorMath (used internally by fontmake) and ufoProcessor. The -aim is to be a minimal implementation that is focussed on using ufoLib2 for font data -abstraction, varLib for instance computation and fontMath as a font data shell for -instance computation directly and exclusively. - -At the time of this writing, varLib lacks support for anisotropic (x, y) locations and -extrapolation. +This alias is only kept for backward compatibility. """ -import copy -import logging -import typing -from typing import Any, Dict, List, Mapping, Set, Tuple, Union - -import attr -import fontMath -import fontTools.designspaceLib as designspaceLib -import fontTools.misc.fixedTools -import fontTools.varLib as varLib -import ufoLib2 - -logger = logging.getLogger(__name__) - -# Use the same rounding function used by varLib to round things for the variable font -# to reduce differences between the variable and static instances. -fontMath.mathFunctions.setRoundIntegerFunction(fontTools.misc.fixedTools.otRound) - -# Stand-in type for any of the fontMath classes we use. -FontMathObject = Union[fontMath.MathGlyph, fontMath.MathInfo, fontMath.MathKerning] - -# MutatorMath-style location mapping type, i.e. -# `{"wght": 1.0, "wdth": 0.0, "bleep": 0.5}`. -# LocationKey is a Location turned into a tuple so we can use it as a dict key. -Location = Mapping[str, float] -LocationKey = Tuple[Tuple[str, float], ...] - -# Type of mapping of axes to their minimum, default and maximum values, i.e. -# `{"wght": (100.0, 400.0, 900.0), "wdth": (75.0, 100.0, 100.0)}`. -AxisBounds = Dict[str, Tuple[float, float, float]] - -# For mapping `wdth` axis user values to the OS2 table's width class field. -WDTH_VALUE_TO_OS2_WIDTH_CLASS = { - 50: 1, - 62.5: 2, - 75: 3, - 87.5: 4, - 100: 5, - 112.5: 6, - 125: 7, - 150: 8, - 200: 9, -} - -# Font info fields that are not interpolated and should be copied from the -# default font to the instance. -# -# fontMath at the time of this writing handles the following attributes: -# https://github.com/robotools/fontMath/blob/0.5.0/Lib/fontMath/mathInfo.py#L360-L422 -# -# From the attributes that are left, we skip instance-specific ones on purpose: -# - guidelines -# - postscriptFontName -# - styleMapFamilyName -# - styleMapStyleName -# - styleName -# - openTypeNameCompatibleFullName -# - openTypeNamePreferredFamilyName -# - openTypeNamePreferredSubfamilyName -# - openTypeNameUniqueID -# - openTypeNameWWSFamilyName -# - openTypeNameWWSSubfamilyName -# - openTypeOS2Panose -# - postscriptFullName -# - postscriptUniqueID -# - woffMetadataUniqueID -# -# Some, we skip because they are deprecated: -# - macintoshFONDFamilyID -# - macintoshFONDName -# - year -# -# This means we implicitly require the `stylename` attribute in the Designspace -# `` element. -UFO_INFO_ATTRIBUTES_TO_COPY_TO_INSTANCES = { - "copyright", - "familyName", - "note", - "openTypeGaspRangeRecords", - "openTypeHeadCreated", - "openTypeHeadFlags", - "openTypeNameDescription", - "openTypeNameDesigner", - "openTypeNameDesignerURL", - "openTypeNameLicense", - "openTypeNameLicenseURL", - "openTypeNameManufacturer", - "openTypeNameManufacturerURL", - "openTypeNameRecords", - "openTypeNameSampleText", - "openTypeNameVersion", - "openTypeOS2CodePageRanges", - "openTypeOS2FamilyClass", - "openTypeOS2Selection", - "openTypeOS2Type", - "openTypeOS2UnicodeRanges", - "openTypeOS2VendorID", - "postscriptDefaultCharacter", - "postscriptForceBold", - "postscriptIsFixedPitch", - "postscriptWindowsCharacterSet", - "trademark", - "versionMajor", - "versionMinor", - "woffMajorVersion", - "woffMetadataCopyright", - "woffMetadataCredits", - "woffMetadataDescription", - "woffMetadataExtensions", - "woffMetadataLicense", - "woffMetadataLicensee", - "woffMetadataTrademark", - "woffMetadataVendor", - "woffMinorVersion", -} - - -# Custom exception for this module -class InstantiatorError(Exception): - pass - - -def process_rules_swaps(rules, location, glyphNames): - """Apply these rules at this location to these glyphnames - - rule order matters - - Return a list of (oldName, newName) in the same order as the rules. - """ - swaps = [] - glyphNames = set(glyphNames) - for rule in rules: - if designspaceLib.evaluateRule(rule, location): - for oldName, newName in rule.subs: - # Here I don't check if the new name is also in glyphNames... - # I guess it should be, so that we can swap, and if it isn't, - # then it's better to error out later when we try to swap, - # instead of silently ignoring the rule here. - if oldName in glyphNames: - swaps.append((oldName, newName)) - return swaps - - -@attr.s(auto_attribs=True, frozen=True, slots=True) -class Instantiator: - """Data class that holds all necessary information to generate a static - font instance object at an arbitary location within the design space.""" - - axis_bounds: AxisBounds # Design space! - copy_feature_text: str - copy_nonkerning_groups: Mapping[str, List[str]] - copy_info: ufoLib2.objects.Info - copy_lib: Mapping[str, Any] - default_design_location: Location - designspace_rules: List[designspaceLib.RuleDescriptor] - glyph_mutators: Mapping[str, "Variator"] - glyph_name_to_unicodes: Dict[str, List[int]] - info_mutator: "Variator" - kerning_mutator: "Variator" - round_geometry: bool - skip_export_glyphs: List[str] - special_axes: Mapping[str, designspaceLib.AxisDescriptor] - - @classmethod - def from_designspace( - cls, - designspace: designspaceLib.DesignSpaceDocument, - round_geometry: bool = True, - ): - """Instantiates a new data class from a Designspace object.""" - if designspace.default is None: - raise InstantiatorError(_error_msg_no_default(designspace)) - - if any(hasattr(axis, "values") for axis in designspace.axes): - raise InstantiatorError( - "The given designspace has one or more discrete (= non-interpolating) " - "axes. You should split this designspace into smaller interpolating " - "spaces and use the Instantiator on each. See the method " - "`fontTools.designspaceLib.split.splitInterpolable()`" - ) - - if any(anisotropic(instance.location) for instance in designspace.instances): - raise InstantiatorError( - "The Designspace contains anisotropic instance locations, which are " - "not supported by varLib. Look for and remove all 'yvalue=\"...\"' or " - "use MutatorMath instead." - ) - - designspace.loadSourceFonts(ufoLib2.Font.open) - - # The default font (default layer) determines which glyphs are interpolated, - # because the math behind varLib and MutatorMath uses the default font as the - # point of reference for all data. - default_font = designspace.default.font - non_default_layer_name = designspace.default.layerName - - glyph_names: Set[str] = set(default_font.keys()) - - if non_default_layer_name is not None: - try: - layer = default_font.layers[non_default_layer_name] - except KeyError as e: - raise InstantiatorError( - f"Layer {non_default_layer_name!r} not found " - f"in {designspace.default.filename}" - ) from e - layer = default_font.layers[non_default_layer_name] - glyph_names = layer.keys() - logger.info(f"Building from layer {layer.name}") - - for source in designspace.sources: - other_names = set(source.font.keys()) - diff_names = other_names - glyph_names - if diff_names: - max_diff_glyphs = 10 - logger.warning( - "The source %s (%s)%s contains glyphs that are missing from the " - "default source, which will be ignored: %s%s; if this is unintended, " - "check that these glyphs have the exact same name as the " - "corresponding glyphs in the default source.", - source.name, - source.filename, - ( - f" [layer: {source.layerName}]" - if non_default_layer_name is not None - else "" - ), - ", ".join(sorted(diff_names)[0:max_diff_glyphs]), - ( - f"... ({len(diff_names)} total)" - if len(diff_names) > max_diff_glyphs - else "" - ), - ) - - # Construct Variators - axis_bounds: AxisBounds = {} # Design space! - axis_order: List[str] = [] - special_axes = {} - for axis in designspace.axes: - axis_order.append(axis.name) - axis_bounds[axis.name] = ( - axis.map_forward(axis.minimum), - axis.map_forward(axis.default), - axis.map_forward(axis.maximum), - ) - # Some axes relate to existing OpenType fields and get special attention. - if axis.tag in {"wght", "wdth", "slnt"}: - special_axes[axis.tag] = axis - - masters_info = collect_info_masters(designspace, axis_bounds) - try: - info_mutator = Variator.from_masters(masters_info, axis_order) - except varLib.errors.VarLibError as e: - raise InstantiatorError( - f"Cannot set up fontinfo for interpolation: {e}'" - ) from e - - masters_kerning = collect_kerning_masters(designspace, axis_bounds) - try: - kerning_mutator = Variator.from_masters(masters_kerning, axis_order) - except varLib.errors.VarLibError as e: - raise InstantiatorError( - f"Cannot set up kerning for interpolation: {e}'" - ) from e - - glyph_mutators: Dict[str, Variator] = {} - glyph_name_to_unicodes: Dict[str, List[int]] = {} - for glyph_name in glyph_names: - items = collect_glyph_masters(designspace, glyph_name, axis_bounds) - try: - glyph_mutators[glyph_name] = Variator.from_masters(items, axis_order) - except varLib.errors.VarLibError as e: - raise InstantiatorError( - f"Cannot set up glyph {glyph_name} for interpolation: {e}'" - ) from e - glyph_name_to_unicodes[glyph_name] = default_font[glyph_name].unicodes - - # Construct defaults to copy over - copy_feature_text: str = default_font.features.text - copy_nonkerning_groups: Mapping[str, List[str]] = { - key: glyph_names - for key, glyph_names in default_font.groups.items() - if not key.startswith(("public.kern1.", "public.kern2.")) - } # Kerning groups are taken care of by the kerning Variator. - copy_info: ufoLib2.objects.Info = default_font.info - copy_lib: Mapping[str, Any] = default_font.lib - - # The list of glyphs-not-to-export-and-decompose-where-used-as-a-component is - # supposed to be taken from the Designspace when a Designspace is used as the - # starting point of the compilation process. It should be exported to all - # instance libs, where the ufo2ft compilation functions will pick it up. - skip_export_glyphs = designspace.lib.get("public.skipExportGlyphs", []) - - return cls( - axis_bounds, - copy_feature_text, - copy_nonkerning_groups, - copy_info, - copy_lib, - designspace.default.location, - designspace.rules, - glyph_mutators, - glyph_name_to_unicodes, - info_mutator, - kerning_mutator, - round_geometry, - skip_export_glyphs, - special_axes, - ) - - def generate_instance( - self, instance: designspaceLib.InstanceDescriptor - ) -> ufoLib2.Font: - """Generate an interpolated instance font object for an - InstanceDescriptor.""" - if anisotropic(instance.location): - raise InstantiatorError( - f"Instance {instance.familyName}-" - f"{instance.styleName}: Anisotropic location " - f"{instance.location} not supported by varLib." - ) - - font = ufoLib2.Font() - - # Instances may leave out locations that match the default source, so merge - # default location with the instance's location. - location = {**self.default_design_location, **instance.location} - location_normalized = varLib.models.normalizeLocation( - location, self.axis_bounds - ) - - # Kerning - kerning_instance = self.kerning_mutator.instance_at(location_normalized) - if self.round_geometry: - kerning_instance.round() - kerning_instance.extractKerning(font) - - # Info - self._generate_instance_info(instance, location_normalized, location, font) - - # Non-kerning groups. Kerning groups have been taken care of by the kerning - # instance. - for key, glyph_names in self.copy_nonkerning_groups.items(): - font.groups[key] = [name for name in glyph_names] - - # Features - font.features.text = self.copy_feature_text - - # Lib - # 1. Copy the default lib to the instance. - font.lib = typing.cast(dict, copy.deepcopy(self.copy_lib)) - # 2. Copy the Designspace's skipExportGlyphs list over to the UFO to - # make sure it wins over the default UFO one. - font.lib["public.skipExportGlyphs"] = [name for name in self.skip_export_glyphs] - # 3. Write _design_ location to instance's lib. - font.lib["designspace.location"] = [loc for loc in location.items()] - - # Glyphs - for glyph_name, glyph_mutator in self.glyph_mutators.items(): - glyph = font.newGlyph(glyph_name) - - try: - glyph_instance = glyph_mutator.instance_at(location_normalized) - - if self.round_geometry: - glyph_instance = glyph_instance.round() - - # onlyGeometry=True does not set name and unicodes, in ufoLib2 we can't - # modify a glyph's name. Copy unicodes from default font. - glyph_instance.extractGlyph(glyph, onlyGeometry=True) - except Exception as e: - # TODO: Figure out what exceptions fontMath/varLib can throw. - # By default, explode if we cannot generate a glyph instance for - # whatever reason (usually outline incompatibility)... - if glyph_name not in self.skip_export_glyphs: - raise InstantiatorError( - f"Failed to generate instance of glyph {glyph_name!r}: " - f"{str(e)}. (Note: the most common cause for an error here is " - "that the glyph outlines are not point-for-point compatible or " - "have the same starting point or are in the same order in all " - "masters.)" - ) from e - - # ...except if the glyph is in public.skipExportGlyphs and would - # therefore be removed from the compiled font anyway. There's not much - # we can do except leave it empty in the instance and tell the user. - logger.warning( - "Failed to generate instance of glyph '%s', which is marked as " - "non-exportable. Glyph will be left empty. Failure reason: %s", - glyph_name, - e, - ) - - glyph.unicodes = [uv for uv in self.glyph_name_to_unicodes[glyph_name]] - - # Process rules - glyph_names_list = self.glyph_mutators.keys() - # The order of the swaps below is independent of the order of glyph names. - # It depends on the order of the s in the designspace rules. - swaps = process_rules_swaps(self.designspace_rules, location, glyph_names_list) - for name_old, name_new in swaps: - if name_old != name_new: - swap_glyph_names(font, name_old, name_new) - - return font - - def _generate_instance_info( - self, - instance: designspaceLib.InstanceDescriptor, - location_normalized: Location, - location: Location, - font: ufoLib2.Font, - ) -> None: - """Generate fontinfo related attributes. - - Separate, as fontinfo treatment is more extensive than the rest. - """ - info_instance = self.info_mutator.instance_at(location_normalized) - if self.round_geometry: - info_instance = info_instance.round() - info_instance.extractInfo(font.info) - - # Copy non-interpolating metadata from the default font. - for attribute in UFO_INFO_ATTRIBUTES_TO_COPY_TO_INSTANCES: - if hasattr(self.copy_info, attribute): - setattr( - font.info, - attribute, - copy.deepcopy(getattr(self.copy_info, attribute)), - ) - - # TODO: multilingual names to replace possibly existing name records. - if instance.familyName: - font.info.familyName = instance.familyName - if instance.styleName is None: - logger.warning( - "The given instance or instance at location %s is missing the " - "stylename attribute, which is required. Copying over the styleName " - "from the default font, which is probably wrong.", - location, - ) - font.info.styleName = self.copy_info.styleName - else: - font.info.styleName = instance.styleName - if instance.postScriptFontName: - font.info.postscriptFontName = instance.postScriptFontName - if instance.styleMapFamilyName: - font.info.styleMapFamilyName = instance.styleMapFamilyName - if instance.styleMapStyleName: - font.info.styleMapStyleName = instance.styleMapStyleName - - # If the masters haven't set the OS/2 weight and width class, use the - # user-space values ("input") of the axis mapping in the Designspace file for - # weight and width axes, if they exist. The slnt axis' value maps 1:1 to - # italicAngle. Clamp the values to the valid ranges. - if info_instance.openTypeOS2WeightClass is None and "wght" in self.special_axes: - weight_axis = self.special_axes["wght"] - font.info.openTypeOS2WeightClass = weight_class_from_wght_value( - weight_axis.map_backward(location[weight_axis.name]) - ) - if info_instance.openTypeOS2WidthClass is None and "wdth" in self.special_axes: - width_axis = self.special_axes["wdth"] - font.info.openTypeOS2WidthClass = width_class_from_wdth_value( - width_axis.map_backward(location[width_axis.name]) - ) - if info_instance.italicAngle is None and "slnt" in self.special_axes: - slant_axis = self.special_axes["slnt"] - font.info.italicAngle = italic_angle_from_slnt_value( - slant_axis.map_backward(location[slant_axis.name]) - ) - - -def _error_msg_no_default(designspace: designspaceLib.DesignSpaceDocument) -> str: - if any(axis.map for axis in designspace.axes): - bonus_msg = ( - "For axes with a mapping, the 'default' values should have an " - "'input=\"...\"' map value, where the corresponding 'output=\"...\"' " - "value then points to the master source." - ) - else: - bonus_msg = "" - - default_location = ", ".join( - f"{k}: {v}" for k, v in designspace.newDefaultLocation().items() - ) - - return ( - "Can't generate UFOs from this Designspace because there is no default " - "master source at location {!r}. Check that all 'default' " - "values of all axes together point to a single actual master source. " - "{!s}".format(default_location, bonus_msg) - ) - - -def location_to_key(location: Location) -> LocationKey: - """Converts a Location into a sorted tuple so it can be used as a dict - key.""" - return tuple(sorted(location.items())) - - -def anisotropic(location: Location) -> bool: - """Tests if any single location value is a MutatorMath-style anisotropic - value, i.e. is a tuple of (x, y).""" - return any(isinstance(v, tuple) for v in location.values()) - - -def collect_info_masters( - designspace: designspaceLib.DesignSpaceDocument, axis_bounds: AxisBounds -) -> List[Tuple[Location, FontMathObject]]: - """Return master Info objects wrapped by MathInfo.""" - locations_and_masters = [] - - for source in designspace.sources: - if source.layerName is not None and source is not designspace.default: - continue # No font info in non-default source layers. - - normalized_location = varLib.models.normalizeLocation( - source.location, axis_bounds - ) - locations_and_masters.append( - (normalized_location, fontMath.MathInfo(source.font.info)) - ) - - return locations_and_masters - - -def collect_kerning_masters( - designspace: designspaceLib.DesignSpaceDocument, axis_bounds: AxisBounds -) -> List[Tuple[Location, FontMathObject]]: - """Return master kerning objects wrapped by MathKerning.""" - - # Always take the groups from the default source. This also avoids fontMath - # making a union of all groups it is given. - groups = designspace.default.font.groups - - locations_and_masters = [] - - for source in designspace.sources: - if source.layerName is not None and source is not designspace.default: - continue # No kerning in non-default source layers. - - # If a source has groups, they should match the default's. - if source.font.groups and source.font.groups != groups: - logger.warning( - "The source %s (%s) contains different groups than the default source. " - "The default source's groups will be used for the instances.", - source.name, - source.filename, - ) - - # This assumes that groups of all sources are the same. - normalized_location = varLib.models.normalizeLocation( - source.location, axis_bounds - ) - locations_and_masters.append( - (normalized_location, fontMath.MathKerning(source.font.kerning, groups)) - ) - - return locations_and_masters - - -def collect_glyph_masters( - designspace: designspaceLib.DesignSpaceDocument, - glyph_name: str, - axis_bounds: AxisBounds, -) -> List[Tuple[Location, FontMathObject]]: - """Return master glyph objects for glyph_name wrapped by MathGlyph. - - Note: skips empty source glyphs if the default glyph is not empty to almost match - what ufoProcessor is doing. In e.g. Mutator Sans, the 'S.closed' glyph is left - empty in one source layer. One could treat this as a source error, but ufoProcessor - specifically has code to skip that empty glyph and carry on. - """ - locations_and_masters = [] - default_glyph_empty = False - other_glyph_empty = False - - for source in designspace.sources: - if source.layerName is None: # Source font. - source_layer = source.font.layers.defaultLayer - else: # Source layer. - source_layer = source.font.layers[source.layerName] - - # Sparse fonts do not and layers may not contain every glyph. - if glyph_name not in source_layer: - continue - - source_glyph = source_layer[glyph_name] - - if not (source_glyph.contours or source_glyph.components): - if source is designspace.findDefault(): - default_glyph_empty = True - else: - other_glyph_empty = True - - normalized_location = varLib.models.normalizeLocation( - source.location, axis_bounds - ) - locations_and_masters.append( - (normalized_location, fontMath.MathGlyph(source_glyph, strict=True)) - ) - - # Filter out empty glyphs if the default glyph is not empty. - if not default_glyph_empty and other_glyph_empty: - locations_and_masters = [ - (loc, master) - for loc, master in locations_and_masters - if master.contours or master.components - ] - - return locations_and_masters - - -def width_class_from_wdth_value(wdth_user_value) -> int: - """Return the OS/2 width class from the wdth axis user value. - - The OpenType 1.8.3 specification states: - - When mapping from 'wdth' values to usWidthClass, interpolate fractional - values between the mapped values and then round, and clamp to the range - 1 to 9. - - "Mapped values" probably means the in-percent numbers layed out for the OS/2 - width class, so we are forcing these numerical semantics on the user values - of the wdth axis. - """ - width_user_value = min(max(wdth_user_value, 50), 200) - width_user_value_mapped = varLib.models.piecewiseLinearMap( - width_user_value, WDTH_VALUE_TO_OS2_WIDTH_CLASS - ) - return fontTools.misc.fixedTools.otRound(width_user_value_mapped) - - -def weight_class_from_wght_value(wght_user_value) -> int: - """Return the OS/2 weight class from the wght axis user value.""" - weight_user_value = min(max(wght_user_value, 1), 1000) - return fontTools.misc.fixedTools.otRound(weight_user_value) - - -def italic_angle_from_slnt_value(slnt_user_value) -> Union[int, float]: - """Return the italic angle from the slnt axis user value.""" - slant_user_value = min(max(slnt_user_value, -90), 90) - return slant_user_value - - -def swap_glyph_names(font: ufoLib2.Font, name_old: str, name_new: str): - """Swap two existing glyphs in the default layer of a font (outlines, - width, component references, kerning references, group membership). - - The idea behind swapping instead of overwriting is explained in - https://github.com/fonttools/fonttools/tree/main/Doc/source/designspaceLib#ufo-instances. - We need to keep the old glyph around in case any other glyph references - it; glyphs that are not explicitly substituted by rules should not be - affected by the rule application. - - The .unicodes are not swapped. The rules mechanism is supposed to swap - glyphs, not characters. - """ - - if name_old not in font or name_new not in font: - raise InstantiatorError( - "Cannot swap glyphs {!r} and {!r}, as either or both are missing".format( - name_old, name_new - ) - ) - - # 1. Swap outlines and glyph width. Ignore lib content and other properties. - glyph_swap = ufoLib2.objects.Glyph(name="temporary_swap_glyph") - glyph_old = font[name_old] - glyph_new = font[name_new] - - p = glyph_swap.getPointPen() - glyph_old.drawPoints(p) - glyph_swap.width = glyph_old.width - - glyph_old.clearContours() - glyph_old.clearComponents() - p = glyph_old.getPointPen() - glyph_new.drawPoints(p) - glyph_old.width = glyph_new.width - - glyph_new.clearContours() - glyph_new.clearComponents() - p = glyph_new.getPointPen() - glyph_swap.drawPoints(p) - glyph_new.width = glyph_swap.width - - # 2. Swap anchors. - glyph_swap.anchors = glyph_old.anchors - glyph_old.anchors = glyph_new.anchors - glyph_new.anchors = glyph_swap.anchors - - # 3. Remap components. - for g in font: - for c in g.components: - if c.baseGlyph == name_old: - c.baseGlyph = name_new - elif c.baseGlyph == name_new: - c.baseGlyph = name_old - - # 4. Swap literal names in kerning. - kerning_new = {} - for first, second in font.kerning.keys(): - value = font.kerning[(first, second)] - if first == name_old: - first = name_new - elif first == name_new: - first = name_old - if second == name_old: - second = name_new - elif second == name_new: - second = name_old - kerning_new[(first, second)] = value - font.kerning = kerning_new - - # 5. Swap names in groups. - for group_name, group_members in font.groups.items(): - group_members_new = [] - for name in group_members: - if name == name_old: - group_members_new.append(name_new) - elif name == name_new: - group_members_new.append(name_old) - else: - group_members_new.append(name) - font.groups[group_name] = group_members_new - - -@attr.s(auto_attribs=True, frozen=True, slots=True) -class Variator: - """A middle-man class that ingests a mapping of normalized locations to - masters plus axis definitions and uses varLib to spit out interpolated - instances at specified normalized locations. - - fontMath objects stand in for the actual master objects from the - UFO. Upon generating an instance, these objects have to be extracted - into an actual UFO object. - """ - - masters: List[FontMathObject] - location_to_master: Mapping[LocationKey, FontMathObject] - model: varLib.models.VariationModel - - @classmethod - def from_masters( - cls, items: List[Tuple[Location, FontMathObject]], axis_order: List[str] - ): - masters = [] - master_locations = [] - location_to_master = {} - for normalized_location, master in items: - master_locations.append(normalized_location) - masters.append(master) - location_to_master[location_to_key(normalized_location)] = master - model = varLib.models.VariationModel(master_locations, axis_order) - - return cls(masters, location_to_master, model) - - def instance_at(self, normalized_location: Location) -> FontMathObject: - """Return a FontMathObject for the specified location ready to be - inflated. - - If an instance location matches a master location, this method - returns the master data instead of running through varLib. This - is both an optimization _and_ it enables having a Designspace - with instances matching their masters without requiring them to - be compatible. Glyphs.app works this way; it will only generate - a font from an instance, but compatibility is only required if - there is actual interpolation to be done. This enables us to - store incompatible bare masters in one Designspace and having - arbitrary instance data applied to them. - """ - normalized_location_key = location_to_key(normalized_location) - if normalized_location_key in self.location_to_master: - return copy.deepcopy(self.location_to_master[normalized_location_key]) - - return self.model.interpolateFromMasters(normalized_location, self.masters) +from ufo2ft.instantiator import * # noqa: F403, F401 diff --git a/requirements.txt b/requirements.txt index 7d683b33..ebed056e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,10 @@ fonttools[lxml,repacker,ufo,unicode]==4.50.0; platform_python_implementation == 'CPython' fonttools[repacker,ufo,unicode]==4.50.0; platform_python_implementation != 'CPython' -glyphsLib==6.6.6 -ufo2ft==3.1.0 +glyphsLib==6.7.0 +ufo2ft==3.2.0 fontMath==0.9.3 booleanOperations==0.9.0 ufoLib2==0.16.0 -attrs==23.2.0 cffsubr==0.3.0 compreffor==0.5.5 ttfautohint-py==0.5.1 diff --git a/setup.py b/setup.py index 5150a219..89ede8ea 100644 --- a/setup.py +++ b/setup.py @@ -29,11 +29,10 @@ dep_versions = { - "attrs": ">=19", "fontMath": ">=0.9.3", - "fonttools": ">=4.48.1", - "glyphsLib": ">=6.6.3", - "ufo2ft": ">=3.0.1", + "fonttools": ">=4.50.0", + "glyphsLib": ">=6.7.0", + "ufo2ft": ">=3.2.0", "ufoLib2": ">=0.16.0", } diff --git a/tests/data/IntermediateComponents.glyphs b/tests/data/IntermediateComponents.glyphs new file mode 100644 index 00000000..8b7e5229 --- /dev/null +++ b/tests/data/IntermediateComponents.glyphs @@ -0,0 +1,1459 @@ +{ +.appVersion = "3248"; +.formatVersion = 3; +axes = ( +{ +name = Weight; +tag = wght; +} +); +customParameters = ( +{ +name = "Write DisplayStrings"; +value = 0; +}, +{ +name = "Write lastChange"; +value = 0; +} +); +date = "2024-02-20 10:47:07 +0000"; +familyName = "Intermediate Components Test"; +fontMaster = ( +{ +axesValues = ( +400 +); +id = m01; +metricValues = ( +{ +over = 16; +pos = 800; +}, +{ +over = 16; +pos = 700; +}, +{ +over = 16; +pos = 500; +}, +{ +over = -16; +}, +{ +over = -16; +pos = -200; +}, +{ +} +); +name = Regular; +}, +{ +axesValues = ( +900 +); +iconName = Bold; +id = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +metricValues = ( +{ +over = 16; +pos = 800; +}, +{ +over = 16; +pos = 700; +}, +{ +over = 16; +pos = 500; +}, +{ +over = -16; +}, +{ +over = -16; +pos = -200; +}, +{ +} +); +name = Black; +} +); +glyphs = ( +{ +color = 0; +glyphname = a; +layers = ( +{ +anchors = ( +{ +name = bottom; +pos = (194,-14); +}, +{ +name = cedilla; +pos = (352,0); +}, +{ +name = top; +pos = (221,534); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(380,-16,l), +(382,377,l), +(219,503,l), +(30,369,l), +(92,362,l), +(206,454,l), +(320,367,l), +(316,-16,l) +); +}, +{ +closed = 1; +nodes = ( +(352,58,l), +(341,100,l), +(127,24,l), +(76,50,l), +(65,130,l), +(117,179,l), +(339,194,l), +(339,231,l), +(110,215,l), +(19,142,l), +(30,13,l), +(118,-35,l) +); +} +); +width = 400; +}, +{ +anchors = ( +{ +name = bottom; +pos = (208,-14); +}, +{ +name = cedilla; +pos = (363,0); +}, +{ +name = top; +pos = (234,552); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(439,-16,l), +(437,441,l), +(241,511,l), +(19,404,l), +(75,279,l), +(229,362,l), +(285,342,l), +(285,-26,l) +); +}, +{ +closed = 1; +nodes = ( +(376,32,l), +(362,106,l), +(213,69,l), +(182,93,l), +(172,135,l), +(194,168,l), +(356,172,l), +(356,260,l), +(121,260,l), +(19,179,l), +(19,13,l), +(135,-35,l) +); +} +); +width = 450; +} +); +unicode = 97; +}, +{ +color = 4; +glyphname = aacute; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = a; +}, +{ +pos = (67,-30); +ref = acutecomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = a; +}, +{ +pos = (93,-12); +ref = acutecomb; +} +); +width = 450; +}, +{ +associatedMasterId = m01; +attr = { +coordinates = ( +500 +); +}; +layerId = "28A9017D-3591-4F21-A420-4F561DE5E624"; +name = "20 Feb 24 at 12:22"; +shapes = ( +{ +ref = a; +}, +{ +alignment = -1; +pos = (21,-206); +ref = acutecomb; +} +); +width = 410; +} +); +unicode = 225; +}, +{ +color = 9; +glyphname = i; +layers = ( +{ +anchors = ( +{ +name = bottom; +pos = (150,0); +}, +{ +name = top; +pos = (158,658); +} +); +layerId = m01; +shapes = ( +{ +ref = idotless; +}, +{ +closed = 1; +nodes = ( +(179,555,o), +(198,574,o), +(198,598,cs), +(198,621,o), +(179,640,o), +(156,640,cs), +(132,640,o), +(113,621,o), +(113,598,cs), +(113,574,o), +(132,555,o), +(156,555,cs) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = bottom; +pos = (175,0); +}, +{ +name = top; +pos = (188,736); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = idotless; +}, +{ +closed = 1; +nodes = ( +(229,555,o), +(261,587,o), +(261,629,cs), +(261,668,o), +(229,701,o), +(189,701,cs), +(148,701,o), +(115,668,o), +(115,629,cs), +(115,587,o), +(148,555,o), +(189,555,cs) +); +} +); +width = 350; +} +); +unicode = 105; +}, +{ +color = 0; +glyphname = idotless; +layers = ( +{ +anchors = ( +{ +name = bottom; +pos = (154,-30); +}, +{ +name = top; +pos = (149,557); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(181,-12,l), +(185,516,l), +(118,515,l), +(117,-12,l) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = bottom; +pos = (172,-45); +}, +{ +name = top; +pos = (192,549); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(251,-12,l), +(255,516,l), +(108,515,l), +(107,-12,l) +); +} +); +width = 350; +}, +{ +anchors = ( +{ +name = bottom; +pos = (172,-40); +}, +{ +name = top; +pos = (175,552); +} +); +associatedMasterId = m01; +attr = { +coordinates = ( +700 +); +}; +layerId = "84803E96-8331-4B52-8DF0-C5C0A276FC0E"; +name = "20 Feb 24 at 11:59"; +shapes = ( +{ +closed = 1; +nodes = ( +(223,-12,l), +(167,515,l), +(167,515,l), +(111,-12,l) +); +} +); +width = 330; +} +); +unicode = 305; +}, +{ +color = 4; +glyphname = iacute; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = idotless; +}, +{ +pos = (-5,-7); +ref = acutecomb; +} +); +width = 300; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = idotless; +}, +{ +pos = (51,-15); +ref = acutecomb; +} +); +width = 350; +} +); +unicode = 237; +}, +{ +color = 4; +glyphname = imacron; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = idotless; +}, +{ +pos = (-28,-39); +ref = macroncomb; +} +); +width = 300; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = idotless; +}, +{ +pos = (15,-37); +ref = macroncomb; +} +); +width = 350; +} +); +unicode = 299; +}, +{ +color = 0; +glyphname = n; +layers = ( +{ +anchors = ( +{ +name = bottom; +pos = (245,3); +}, +{ +name = top; +pos = (278,585); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(91,514,l), +(91,1,l), +(17,0,l), +(20,518,l) +); +}, +{ +closed = 1; +nodes = ( +(197,536,l), +(293,553,l), +(397,541,l), +(482,442,l), +(478,3,l), +(400,0,l), +(408,435,l), +(350,485,l), +(306,490,l), +(235,479,l), +(61,379,l), +(44,415,l) +); +} +); +width = 500; +}, +{ +anchors = ( +{ +name = bottom; +pos = (272,1); +}, +{ +name = top; +pos = (287,610); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(160,514,l), +(160,1,l), +(29,-2,l), +(32,516,l) +); +}, +{ +closed = 1; +nodes = ( +(243,536,l), +(332,553,l), +(436,541,l), +(521,442,l), +(517,3,l), +(379,0,l), +(387,405,l), +(339,455,l), +(315,460,l), +(244,449,l), +(100,379,l), +(83,415,l) +); +} +); +width = 550; +} +); +unicode = 110; +}, +{ +color = 4; +glyphname = nacute; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = n; +}, +{ +pos = (124,21); +ref = acutecomb; +} +); +width = 500; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = n; +}, +{ +pos = (146,46); +ref = acutecomb; +} +); +width = 550; +} +); +unicode = 324; +}, +{ +color = 4; +glyphname = ncommaaccent; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = n; +}, +{ +pos = (56,3); +ref = commaaccentcomb; +} +); +width = 500; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = n; +}, +{ +pos = (79,1); +ref = commaaccentcomb; +} +); +width = 550; +} +); +unicode = 326; +}, +{ +color = 4; +glyphname = nmacronbelow; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = n; +}, +{ +pos = (52,34); +ref = macronbelowcomb; +} +); +width = 500; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = n; +}, +{ +pos = (79,25); +ref = macronbelowcomb; +} +); +width = 550; +}, +{ +associatedMasterId = m01; +attr = { +coordinates = ( +600 +); +}; +layerId = "4228318B-F98E-4B70-9AB2-553F56895EBF"; +name = "11 Mar 24 at 18:33"; +shapes = ( +{ +ref = n; +}, +{ +alignment = -1; +pos = (63,111); +ref = macronbelowcomb; +} +); +width = 520; +} +); +unicode = 7753; +}, +{ +category = Letter; +color = 9; +glyphname = aacutemacronbelowcedilla; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = aacutecedilla; +}, +{ +alignment = -1; +pos = (-76,3); +ref = macronbelowcomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = aacutecedilla; +}, +{ +alignment = -1; +pos = (3,-61); +ref = macronbelowcomb; +scale = (0.7124,0.4629); +} +); +width = 450; +} +); +script = latin; +unicode = 57360; +}, +{ +category = Letter; +color = 4; +glyphname = acedilla; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = a; +}, +{ +pos = (175,0); +ref = cedillacomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = a; +}, +{ +pos = (195,0); +ref = cedillacomb; +} +); +width = 450; +} +); +script = latin; +unicode = 57344; +}, +{ +category = Letter; +color = 4; +glyphname = iacutecedilla; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = iacute; +}, +{ +pos = (-28,0); +ref = cedillacomb; +} +); +width = 300; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = iacute; +}, +{ +pos = (20,0); +ref = cedillacomb; +} +); +width = 350; +} +); +script = latin; +unicode = 57346; +}, +{ +category = Letter; +color = 4; +glyphname = icedilla; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = i; +}, +{ +pos = (-24,0); +ref = cedillacomb; +} +); +width = 300; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = i; +}, +{ +pos = (11,0); +ref = cedillacomb; +} +); +width = 350; +} +); +script = latin; +unicode = 57347; +}, +{ +category = Letter; +color = 4; +glyphname = acommaaccent; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = a; +}, +{ +pos = (5,-14); +ref = commaaccentcomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = a; +}, +{ +pos = (15,-14); +ref = commaaccentcomb; +} +); +width = 450; +} +); +script = latin; +unicode = 57348; +}, +{ +category = Letter; +color = 4; +glyphname = icommaaccent; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = i; +}, +{ +pos = (-39,0); +ref = commaaccentcomb; +} +); +width = 300; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = i; +}, +{ +pos = (-18,0); +ref = commaaccentcomb; +} +); +width = 350; +} +); +script = latin; +unicode = 57349; +}, +{ +category = Letter; +color = 4; +glyphname = aacutecommaaccent; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = aacute; +}, +{ +pos = (5,-14); +ref = commaaccentcomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = aacute; +}, +{ +pos = (15,-14); +ref = commaaccentcomb; +} +); +width = 450; +} +); +script = latin; +unicode = 57350; +}, +{ +category = Letter; +color = 4; +glyphname = imacroncommaaccent; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = imacron; +}, +{ +pos = (-35,-30); +ref = commaaccentcomb; +} +); +width = 300; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = imacron; +}, +{ +pos = (-21,-45); +ref = commaaccentcomb; +} +); +width = 350; +} +); +script = latin; +unicode = 57351; +}, +{ +category = Letter; +color = 4; +glyphname = nacutemacronbelow; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = nmacronbelow; +}, +{ +pos = (124,21); +ref = acutecomb; +} +); +width = 500; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = nmacronbelow; +}, +{ +pos = (146,46); +ref = acutecomb; +} +); +width = 550; +} +); +script = latin; +unicode = 57352; +}, +{ +category = Letter; +color = 4; +glyphname = aacutecommaaccentcedilla; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = aacutecommaaccent; +}, +{ +pos = (175,0); +ref = cedillacomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = aacutecommaaccent; +}, +{ +pos = (195,0); +ref = cedillacomb; +} +); +width = 450; +} +); +script = latin; +unicode = 57353; +}, +{ +category = Letter; +color = 9; +glyphname = aacutecedilla; +layers = ( +{ +layerId = m01; +shapes = ( +{ +ref = aacute; +}, +{ +pos = (175,0); +ref = cedillacomb; +} +); +width = 400; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +ref = aacute; +}, +{ +pos = (195,0); +ref = cedillacomb; +} +); +width = 450; +}, +{ +associatedMasterId = m01; +attr = { +coordinates = ( +600 +); +}; +layerId = "45B59D28-86B9-4171-9329-C3E9A74583BB"; +name = "20 Feb 24 at 12:09"; +shapes = ( +{ +alignment = -1; +ref = aacute; +}, +{ +pos = (283,0); +ref = cedillacomb; +scale = (0.4241,0.4241); +} +); +width = 420; +} +); +script = Latin; +unicode = 57345; +}, +{ +glyphname = space; +layers = ( +{ +layerId = m01; +width = 200; +}, +{ +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +width = 600; +} +); +unicode = 32; +}, +{ +color = 0; +glyphname = acutecomb; +layers = ( +{ +anchors = ( +{ +name = _top; +pos = (154,564); +}, +{ +name = top; +pos = (161,763); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(181,596,l), +(275,726,l), +(198,730,l), +(136,595,l) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = _top; +pos = (141,564); +}, +{ +name = top; +pos = (161,763); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(191,595,l), +(282,730,l), +(158,730,l), +(96,595,l) +); +} +); +width = 300; +} +); +unicode = 769; +}, +{ +color = 0; +glyphname = macroncomb; +layers = ( +{ +anchors = ( +{ +name = _top; +pos = (177,596); +}, +{ +name = top; +pos = (184,713); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(331,693,l), +(332,647,l), +(73,647,l), +(73,692,l) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = _top; +pos = (177,586); +}, +{ +name = top; +pos = (184,713); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(331,693,l), +(332,617,l), +(73,617,l), +(73,692,l) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = _top; +pos = (155,589); +}, +{ +name = top; +pos = (195,700); +} +); +associatedMasterId = m01; +attr = { +coordinates = ( +800 +); +}; +layerId = "10263F3F-EF44-4FC8-A0AE-228E855659CC"; +name = "20 Feb 24 at 12:47"; +shapes = ( +{ +closed = 1; +nodes = ( +(295,631,l), +(278,583,l), +(70,655,l), +(90,700,l) +); +} +); +width = 300; +} +); +unicode = 772; +}, +{ +color = 0; +glyphname = commaaccentcomb; +layers = ( +{ +anchors = ( +{ +name = _bottom; +pos = (189,0); +}, +{ +name = bottom; +pos = (159,-250); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(223,-49,l), +(165,-218,l), +(116,-216,l), +(155,-49,l) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = _bottom; +pos = (193,0); +}, +{ +name = bottom; +pos = (150,-250); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(251,-49,l), +(165,-218,l), +(86,-218,l), +(123,-49,l) +); +} +); +width = 300; +} +); +unicode = 806; +}, +{ +color = 0; +glyphname = cedillacomb; +layers = ( +{ +anchors = ( +{ +name = _cedilla; +pos = (177,0); +} +); +layerId = m01; +shapes = ( +{ +closed = 1; +nodes = ( +(222,-225,l), +(279,-177,l), +(279,-112,l), +(222,-72,l), +(167,-72,l), +(198,10,l), +(162,7,l), +(118,-112,l), +(204,-111,l), +(229,-131,l), +(229,-166,l), +(193,-194,l), +(134,-211,l), +(153,-255,l) +); +} +); +width = 300; +}, +{ +anchors = ( +{ +name = _cedilla; +pos = (168,0); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +closed = 1; +nodes = ( +(230,-242,l), +(297,-191,l), +(297,-103,l), +(246,-57,l), +(189,-57,l), +(216,7,l), +(126,8,l), +(119,-129,l), +(184,-122,l), +(209,-139,l), +(209,-155,l), +(181,-175,l), +(119,-166,l), +(141,-259,l) +); +} +); +width = 300; +} +); +unicode = 807; +}, +{ +color = 4; +glyphname = macronbelowcomb; +layers = ( +{ +anchors = ( +{ +name = _bottom; +pos = (193,-31); +}, +{ +name = bottom; +pos = (199,-148); +} +); +layerId = m01; +shapes = ( +{ +pos = (9,-761); +ref = macroncomb; +} +); +width = 300; +}, +{ +anchors = ( +{ +name = _bottom; +pos = (193,-24); +}, +{ +name = bottom; +pos = (199,-148); +} +); +layerId = "5F8E3757-2AF7-4D82-9CFF-6C7BC2EAC67D"; +shapes = ( +{ +pos = (0,-741); +ref = macroncomb; +} +); +width = 300; +} +); +unicode = 817; +} +); +metrics = ( +{ +type = ascender; +}, +{ +type = "cap height"; +}, +{ +type = "x-height"; +}, +{ +type = baseline; +}, +{ +type = descender; +}, +{ +type = "italic angle"; +} +); +unitsPerEm = 1000; +versionMajor = 1; +versionMinor = 0; +} diff --git a/tests/test_main.py b/tests/test_main.py index 29af6ea0..f000be42 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -11,6 +11,7 @@ import pytest import ufoLib2 from fontTools.misc.testTools import getXML +from ufo2ft.util import zip_strict import fontmake.__main__ @@ -1239,3 +1240,241 @@ def test_main_export_ufo_json_with_indentation(data_dir, tmp_path, indent_json): assert regular_ufo.read_text().startswith('{\n "features"') else: assert regular_ufo.read_text().startswith('{"features"') + + +def assert_tuple_variation_regions(tvs, expected_regions): + for tv, expected_region in zip_strict(tvs, expected_regions): + assert set(tv.axes.keys()) == set(expected_region.keys()) + for axis in tv.axes: + assert tv.axes[axis] == pytest.approx(expected_region[axis], rel=1e-3) + + +def test_main_sparse_composite_glyphs_variable_ttf(data_dir, tmp_path): + fontmake.__main__.main( + [ + "-g", + str(data_dir / "IntermediateComponents.glyphs"), + "-o", + "variable", + "--output-path", + str(tmp_path / "IntermediateComponents-VF.ttf"), + "--no-production-names", + ] + ) + + vf = fontTools.ttLib.TTFont(tmp_path / "IntermediateComponents-VF.ttf") + assert [a.axisTag for a in vf["fvar"].axes] == ["wght"] + glyf = vf["glyf"] + gvar = vf["gvar"] + + # 'aacute' defines more masters than its components; no problem + aacute = glyf["aacute"] + assert aacute.isComposite() + assert [c.glyphName for c in aacute.components] == ["a", "acutecomb"] + assert_tuple_variation_regions( + gvar.variations["aacute"], + [{"wght": (0.0, 0.2, 1.0)}, {"wght": (0.2, 1.0, 1.0)}], + ) + assert_tuple_variation_regions(gvar.variations["a"], [{"wght": (0.0, 1.0, 1.0)}]) + assert_tuple_variation_regions( + gvar.variations["acutecomb"], [{"wght": (0.0, 1.0, 1.0)}] + ) + + # other composites that use 'aacute' as component don't need additional masters + # as long as they stay composite + aacutecommaaccent = glyf["aacutecommaaccent"] + assert aacutecommaaccent.isComposite() + assert [c.glyphName for c in aacutecommaaccent.components] == [ + "aacute", + "commaaccentcomb", + ] + for name in ("aacutecommaaccent", "commaaccentcomb"): + assert_tuple_variation_regions( + gvar.variations[name], [{"wght": (0.0, 1.0, 1.0)}] + ) + + # 'i' gets decomposed to simple glyph because it originally had a mix of contour + # and component ('idotless'); it also inherits an additional master from the latter + i = glyf["i"] + assert i.numberOfContours == 2 + idotless = glyf["idotless"] + assert idotless.numberOfContours == 1 + for name in ("i", "idotless"): + assert_tuple_variation_regions( + gvar.variations[name], + [ + {"wght": (0.0, 0.6, 1.0)}, + {"wght": (0.6, 1.0, 1.0)}, + ], + ) + + # but 'iacute' and 'imacron' using 'idotless' as a component don't need to be + # decomposed, nor do they inherit any additional master + for name in ("iacute", "imacron"): + glyph = glyf[name] + assert "idotless" in {c.glyphName for c in glyph.components} + assert glyph.isComposite() + assert_tuple_variation_regions( + gvar.variations[name], [{"wght": (0.0, 1.0, 1.0)}] + ) + + # nor does 'iacutecedilla' which uses 'iacute' as components + iacutecedilla = glyf["iacutecedilla"] + assert iacutecedilla.isComposite() + assert [c.glyphName for c in iacutecedilla.components] == ["iacute", "cedillacomb"] + for name in ("iacutecedilla", "iacute", "cedillacomb"): + assert_tuple_variation_regions( + gvar.variations[name], [{"wght": (0.0, 1.0, 1.0)}] + ) + + # 'nmacronbelow' is similar to 'aacute', defines more masters than its components + nmacronbelow = glyf["nmacronbelow"] + assert nmacronbelow.isComposite() + assert [c.glyphName for c in nmacronbelow.components] == ["n", "macronbelowcomb"] + assert_tuple_variation_regions( + gvar.variations["nmacronbelow"], + [ + {"wght": (0.0, 0.4, 1.0)}, + {"wght": (0.4, 1.0, 1.0)}, + ], + ) + assert_tuple_variation_regions(gvar.variations["n"], [{"wght": (0.0, 1.0, 1.0)}]) + assert_tuple_variation_regions( + gvar.variations["macronbelowcomb"], [{"wght": (0.0, 1.0, 1.0)}] + ) + + # 'aacutecedilla' originally comprised 'aacute' and 'cedillacomb' components, but + # since the latter had a 2x2 transform in one intermediate master only (wght=0.4), + # it gets decomposed; it also inherits the additional master from 'aacute' (wght=0.2) + aacutecedilla = glyf["aacutecedilla"] + assert aacutecedilla.numberOfContours == 4 + assert_tuple_variation_regions( + gvar.variations["aacutecedilla"], + [ + {"wght": (0.0, 0.2, 1.0)}, + {"wght": (0.2, 0.4, 1.0)}, + {"wght": (0.4, 1.0, 1.0)}, + ], + ) + + +def test_main_sparse_composite_glyphs_variable_cff2(data_dir, tmp_path): + # CFF/CFF2 have no concept of 'components' in the TrueType sense, so all glyphs + # will be decomposed into contours + fontmake.__main__.main( + [ + "-g", + str(data_dir / "IntermediateComponents.glyphs"), + "-o", + "variable-cff2", + "--output-path", + str(tmp_path / "IntermediateComponents-VF.otf"), + "--no-production-names", + ] + ) + + vf = fontTools.ttLib.TTFont(tmp_path / "IntermediateComponents-VF.otf") + axes = vf["fvar"].axes + assert [a.axisTag for a in axes] == ["wght"] + + font = vf["CFF2"].cff + font.desubroutinize() + top_dict = font.topDictIndex[0] + varstore = top_dict.VarStore.otVarStore + vardata = varstore.VarData + regions = varstore.VarRegionList.Region + charstrings = top_dict.CharStrings + + def assert_charstring_regions(charstring, expected_regions): + vsindex = 0 + for i, token in enumerate(charstring.program): + if token == "vsindex": + vsindex = charstring.program[i - 1] + break + cs_regions = [ + regions[ri].get_support(axes) for ri in vardata[vsindex].VarRegionIndex + ] + for cs_region, expected_region in zip_strict(cs_regions, expected_regions): + assert set(cs_region.keys()) == set(expected_region.keys()) + for axis in cs_region: + assert cs_region[axis] == pytest.approx(expected_region[axis], rel=1e-3) + + # 'aacute' defines an extra intermediate master not present in 'a' or 'acutecomb', + # these get interpolated on the fly as 'aacute' gets decomposed; all other composite + # glyphs in turn using 'aacute' will similarly gain the extra master + for name in ("a", "acutecomb"): + assert_charstring_regions(charstrings[name], [{"wght": (0.0, 1.0, 1.0)}]) + for name in ("aacute", "aacutecommaaccent", "aacutecommaaccentcedilla"): + assert_charstring_regions( + charstrings[name], + [ + {"wght": (0.0, 0.2, 1.0)}, + {"wght": (0.2, 1.0, 1.0)}, + ], + ) + # 'idotless' "infects" all the composite glyphs using it as a component with its + # extra master + for name in ( + "idotless", + "i", + "iacute", + "iacutecedilla", + "icedilla", + "icommaaccent", + ): + assert_charstring_regions( + charstrings[name], + [ + {"wght": (0.0, 0.6, 1.0)}, + {"wght": (0.6, 1.0, 1.0)}, + ], + ) + # 'imacron' etc. inherit extra masters from both 'idotless' and 'macroncomb' + assert_charstring_regions( + charstrings["macroncomb"], + [{"wght": (0.0, 0.8, 1.0)}, {"wght": (0.8, 1.0, 1.0)}], + ) + for name in ("imacron", "imacroncommaaccent"): + assert_charstring_regions( + charstrings[name], + [ + {"wght": (0.0, 0.6, 1.0)}, + {"wght": (0.6, 0.8, 1.0)}, + {"wght": (0.8, 1.0, 1.0)}, + ], + ) + # nothing special here + for name in ("n", "nacute", "ncommaaccent", "acedilla"): + assert_charstring_regions(charstrings[name], [{"wght": (0.0, 1.0, 1.0)}]) + # 'nmacronbelow' defines an extra master (peak wght=0.4) not present in 'n' or + # 'macronbelowcomb' so it ends up with 3 regions (one comes from 'macronbelowcomb') + for name in ("nmacronbelow", "nacutemacronbelow"): + assert_charstring_regions( + charstrings[name], + [ + {"wght": (0.0, 0.4, 1.0)}, + {"wght": (0.4, 0.8, 1.0)}, + {"wght": (0.8, 1.0, 1.0)}, + ], + ) + # 'aacutecedilla' has one intermediate master of its own, plus inherits an other + # from 'aacute' + assert_charstring_regions( + charstrings["aacutecedilla"], + [ + {"wght": (0.0, 0.2, 1.0)}, + {"wght": (0.2, 0.4, 1.0)}, + {"wght": (0.4, 1.0, 1.0)}, + ], + ) + # this monster combines the two additional masters from 'aacutecedilla' and the + # one from 'macronbelowcomb' + assert_charstring_regions( + charstrings["aacutemacronbelowcedilla"], + [ + {"wght": (0.0, 0.2, 1.0)}, + {"wght": (0.2, 0.4, 1.0)}, + {"wght": (0.4, 0.8, 1.0)}, + {"wght": (0.8, 1.0, 1.0)}, + ], + )