diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index e27ca485e..fb0b10867 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -58,6 +58,7 @@ def to_ufos(font, def to_designspace(font, family_name=None, + instance_dir=None, propagate_anchors=True, ufo_module=defcon, minimize_glyphs_diffs=False): @@ -76,6 +77,7 @@ def to_designspace(font, font, ufo_module=ufo_module, family_name=family_name, + instance_dir=instance_dir, propagate_anchors=propagate_anchors, use_designspace=True, minimize_glyphs_diffs=minimize_glyphs_diffs) diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py new file mode 100644 index 000000000..ac59ce4c6 --- /dev/null +++ b/Lib/glyphsLib/builder/axes.py @@ -0,0 +1,403 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import (print_function, division, absolute_import, + unicode_literals) + +from collections import OrderedDict + +from glyphsLib import classes +from glyphsLib.classes import WEIGHT_CODES, WIDTH_CODES +from .constants import (GLYPHS_PREFIX, GLYPHLIB_PREFIX, + FONT_CUSTOM_PARAM_PREFIX, MASTER_CUSTOM_PARAM_PREFIX) + +# This is a key into GSFont.userData to store axes defined in the designspace +AXES_KEY = GLYPHLIB_PREFIX + 'axes' + +# From the spec: https://www.microsoft.com/typography/otspec/os2.htm#wtc +CLASSES_DICT = { + 'wght': { + 100: ('Thin', 100), + 200: ('Extra-light', 200), + 300: ('Light', 300), + 400: ('Regular', 400), + 500: ('Medium', 500), + 600: ('Semi-bold', 600), + 700: ('Bold', 700), + 800: ('Extra-bold', 800), + 900: ('Black', 900), + }, + 'wdth': { + 1: ('Ultra-condensed', 50), + 2: ('Extra-condensed', 62.5), + 3: ('Condensed', 75), + 4: ('Semi-condensed', 87.5), + 5: ('Medium', 100), + 6: ('Semi-expanded', 112.5), + 7: ('Expanded', 125), + 8: ('Extra-expanded', 150), + 9: ('Ultra-expanded', 200), + } +} + + +def class_to_name(axis, ufo_class): + """ + >>> class_to_name('wdth', 7) + 'Expanded' + """ + return CLASSES_DICT[axis][int(ufo_class)][0] + + +def class_to_value(axis, ufo_class): + """ + >>> class_to_value('wdth', 7) + 125 + """ + if axis == 'wght': + # 600.0 => 600, 250 => 250 + return int(ufo_class) + return CLASSES_DICT[axis][int(ufo_class)][1] + + +def user_loc_code_to_value(axis_tag, user_loc): + """ Go from Glyphs UI strings to user space location. + + >>> user_loc_code_to_value('wght', 'ExtraLight') + 250 + >>> user_loc_code_to_value('wdth', 'SemiCondensed') + 87.5 + """ + if axis_tag == 'wght': + return class_to_value('wght', WEIGHT_CODES.get(user_loc, user_loc)) + if axis_tag == 'wdth': + return class_to_value('wdth', WIDTH_CODES.get(user_loc, user_loc)) + + # Currently this function should only be called with a width or weight + raise NotImplementedError + + +def user_loc_value_to_class(axis_tag, user_loc): + """ + >>> user_loc_value_to_class('wdth', 62) + 2 + """ + if axis_tag == 'wght': + return int(user_loc) + return min(sorted(CLASSES_DICT[axis_tag].items()), + key=lambda item: abs(item[1][1] - user_loc))[0] + + +def user_loc_value_to_code(axis_tag, user_loc): + """ + >>> user_loc_value_to_code('wdth', 150) + 'Extra Expanded' + """ + codes = {} + if axis_tag == 'wght': + codes = WEIGHT_CODES + elif axis_tag == 'wdth': + codes = WIDTH_CODES + else: + raise NotImplementedError + class_ = user_loc_value_to_class(axis_tag, user_loc) + return min(sorted((code, class_) for code, class_ in codes.items() + if code is not None), + key=lambda item: abs(item[1] - class_))[0] + + +def to_designspace_axes(self): + if not self.font.masters: + return + regular_master = get_regular_master(self.font) + assert isinstance(regular_master, classes.GSFontMaster) + + for axis_def in get_axis_definitions(self.font): + axis = self.designspace.newAxisDescriptor() + axis.tag = axis_def.tag + axis.name = axis_def.name + + regularDesignLoc = axis_def.get_design_loc(regular_master) + regularUserLoc = axis_def.get_user_loc(regular_master) + + axis.labelNames = {"en": axis_def.name} + instance_mapping = [] + for instance in self.font.instances: + if is_instance_active(instance) or self.minimize_glyphs_diffs: + designLoc = axis_def.get_design_loc(instance) + userLoc = axis_def.get_user_loc(instance) + instance_mapping.append((userLoc, designLoc)) + + # FIXME: (jany) why the next two lines? + if designLoc == regularDesignLoc: + regularUserLoc = userLoc + instance_mapping = sorted(set(instance_mapping)) # avoid duplicates + + master_mapping = [] + for master in self.font.masters: + designLoc = axis_def.get_design_loc(master) + # FIXME: (jany) in latest Glyphs (1113) masters don't have + # a user loc + userLoc = axis_def.get_user_loc(master) + master_mapping.append((userLoc, designLoc)) + master_mapping = sorted(set(master_mapping)) + + minimum = maximum = default = axis_def.default_user_loc + # Prefer the instance-based mapping + mapping = instance_mapping or master_mapping + if mapping: + minimum = min([userLoc for userLoc, _ in mapping]) + maximum = max([userLoc for userLoc, _ in mapping]) + default = min(maximum, max(minimum, regularUserLoc)) # clamp + + if (minimum < maximum or minimum != axis_def.default_user_loc or + len(instance_mapping) > 1 or len(master_mapping) > 1): + axis.map = mapping + axis.minimum = minimum + axis.maximum = maximum + axis.default = default + self.designspace.addAxis(axis) + + +def to_glyphs_axes(self): + weight = None + width = None + customs = [] + for axis in self.designspace.axes: + if axis.tag == 'wght': + weight = axis + elif axis.tag == 'wdth': + width = axis + else: + customs.append(axis) + + axes_parameter = [] + if weight is not None: + axes_parameter.append({'Name': weight.name or 'Weight', 'Tag': 'wght'}) + # TODO: (jany) store other data about this axis? + elif width is not None or customs: + # Add a dumb weight axis to not mess up the indices + # FIXME: (jany) I inferred this requirement from the code in + # https://github.com/googlei18n/glyphsLib/pull/306 + # which seems to suggest that the first value is always weight and + # the second always width + axes_parameter.append({'Name': 'Weight', 'Tag': 'wght'}) + + if width is not None: + axes_parameter.append({'Name': width.name or 'Width', 'Tag': 'wdth'}) + # TODO: (jany) store other data about this axis? + elif customs: + # Add a dumb weight axis to not mess up the indices + # FIXME: (jany) I inferred this requirement from the code in + # https://github.com/googlei18n/glyphsLib/pull/306 + # which seems to suggest that the first value is always weight and + # the second always width + axes_parameter.append({'Name': 'Width', 'Tag': 'wdth'}) + + for custom in customs: + axes_parameter.append({ + 'Name': custom.name, + 'Tag': custom.tag, + }) + # TODO: (jany) store other data about this axis? + + if axes_parameter and not _is_subset_of_default_axes(axes_parameter): + self.font.customParameters['Axes'] = axes_parameter + + if self.minimize_ufo_diffs: + # TODO: (jany) later, when Glyphs can manage general designspace axes + # self.font.userData[AXES_KEY] = [ + # dict( + # tag=axis.tag, + # name=axis.name, + # minimum=axis.minimum, + # maximum=axis.maximum, + # default=axis.default, + # hidden=axis.hidden, + # labelNames=axis.labelNames, + # ) + # for axis in self.designspace.axes + # ] + pass + + +class AxisDefinition(object): + def __init__(self, tag, name, design_loc_key, default_design_loc=0.0, + user_loc_key=None, user_loc_param=None, default_user_loc=0.0): + self.tag = tag + self.name = name + self.design_loc_key = design_loc_key + self.default_design_loc = default_design_loc + self.user_loc_key = user_loc_key + self.user_loc_param = user_loc_param + self.default_user_loc = default_user_loc + + def get_design_loc(self, master_or_instance): + return getattr(master_or_instance, self.design_loc_key) + + def set_design_loc(self, master_or_instance, value): + setattr(master_or_instance, self.design_loc_key, value) + + def get_user_loc(self, master_or_instance): + if self.tag == 'wdth': + # FIXME: (jany) existing test "DesignspaceTestTwoAxes.designspace" + # suggests that the user location is the same as the design loc + # for the width only + return self.get_design_loc(master_or_instance) + + user_loc = self.default_user_loc + if self.user_loc_key is not None: + user_loc = getattr(master_or_instance, self.user_loc_key) + user_loc = user_loc_code_to_value(self.tag, user_loc) + # The custom param takes over the key if it exists + # e.g. for weight: + # key = "weight" -> "Bold" -> 700 + # but param = "weightClass" -> 600 => 600 wins + if self.user_loc_param is not None: + class_ = master_or_instance.customParameters[self.user_loc_param] + if class_ is not None: + user_loc = class_to_value(self.tag, class_) + return user_loc + + def set_user_loc(self, master_or_instance, value): + # Try to set the key if possible, i.e. if there is a key, and + # if there exists a code that can represent the given value, e.g. + # for "weight": 600 can be represented by SemiBold so we use that, + # but for 550 there is no code so we will have to set the custom + # parameter as well. + code = user_loc_value_to_code(self.tag, value) + value_for_code = user_loc_code_to_value(self.tag, code) + if self.user_loc_key is not None: + setattr(master_or_instance, self.user_loc_key, code) + if self.user_loc_param is not None and value != value_for_code: + try: + class_ = user_loc_value_to_class(self.tag, value) + master_or_instance.customParameters[self.user_loc_param] = class_ + except: + pass + + def set_user_loc_code(self, master_or_instance, code): + # The previous method `set_user_loc` will not roundtrip every + # time, for example for value = 600, both "DemiBold" and "SemiBold" + # would work, so we provide this other method to set a specific code. + if self.user_loc_key is not None: + setattr(master_or_instance, self.user_loc_key, code) + + +DEFAULT_AXES_DEFS = ( + AxisDefinition('wght', 'Weight', 'weightValue', 100.0, + 'weight', 'weightClass', 400.0), + AxisDefinition('wdth', 'Width', 'widthValue', 100.0, + 'width', 'widthClass', 100.0), + AxisDefinition('XXXX', 'Custom', 'customValue', 0.0, None, None, 0.0), +) + + +# Adapted from PR https://github.com/googlei18n/glyphsLib/pull/306 +def get_axis_definitions(font): + axesParameter = font.customParameters["Axes"] + if axesParameter is None: + return DEFAULT_AXES_DEFS + + axesDef = [] + designLocKeys = ('weightValue', 'widthValue', 'customValue', + 'customValue1', 'customValue2', 'customValue3') + defaultDesignLocs = (100.0, 100.0, 0.0, 0.0, 0.0, 0.0) + userLocKeys = ('weight', 'width', None, None, None, None) + userLocParams = ('weightClass', 'widthClass', None, None, None, None) + defaultUserLocs = (400.0, 100.0, 0.0, 0.0, 0.0, 0.0) + for idx, axis in enumerate(axesParameter): + axesDef.append(AxisDefinition( + axis.get("Tag", "XXX%d" % idx if idx > 0 else "XXXX"), + axis["Name"], designLocKeys[idx], defaultDesignLocs[idx], + userLocKeys[idx], userLocParams[idx], defaultUserLocs[idx])) + return axesDef + + +def _is_subset_of_default_axes(axes_parameter): + if len(axes_parameter) > 3: + return False + for axis, axis_def in zip(axes_parameter, DEFAULT_AXES_DEFS): + if set(axis.keys()) != {'Name', 'Tag'}: + return False + if axis['Name'] != axis_def.name: + return False + if axis['Tag'] != axis_def.tag: + return False + return True + + +def get_regular_master(font): + """Find the "regular" master among the GSFontMasters. + + Tries to find the master with the passed 'regularName'. + If there is no such master or if regularName is None, + tries to find a base style shared between all masters + (defaulting to "Regular"), and then tries to find a master + with that style name. If there is no master with that name, + returns the first master in the list. + """ + if not font.masters: + return None + regular_name = font.customParameters['Variation Font Origin'] + if regular_name is not None: + for master in font.masters: + if master.name == regular_name: + return master + base_style = find_base_style(font.masters) + if not base_style: + base_style = 'Regular' + for master in font.masters: + if master.name == base_style: + return master + return font.masters[0] + + +def find_base_style(masters): + """Find a base style shared between all masters. + Return empty string if none is found. + """ + if not masters: + return '' + base_style = (masters[0].name or '').split() + for master in masters: + style = master.name.split() + base_style = [s for s in style if s in base_style] + base_style = ' '.join(base_style) + return base_style + + +def is_instance_active(instance): + # Glyphs.app recognizes both "exports=0" and "active=0" as a flag + # to mark instances as inactive. Inactive instances should get ignored. + # https://github.com/googlei18n/glyphsLib/issues/129 + return instance.exports and getattr(instance, 'active', True) + + +def interp(mapping, x): + """Compute the piecewise linear interpolation given by mapping for input x. + + >>> _interp(((1, 1), (2, 4)), 1.5) + 2.5 + """ + mapping = sorted(mapping) + if len(mapping) == 1: + xa, ya = mapping[0] + if xa == x: + return ya + return x + for (xa, ya), (xb, yb) in zip(mapping[:-1], mapping[1:]): + if xa <= x <= xb: + return ya + float(x - xa) / (xb - xa) * (yb - ya) + return x diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index e75482d2c..fa032180b 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -15,7 +15,7 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -from collections import OrderedDict +from collections import OrderedDict, defaultdict import logging import tempfile import os @@ -27,6 +27,7 @@ from glyphsLib import classes, glyphdata_generated from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX +from .axes import DEFAULT_AXES_DEFS, find_base_style, class_to_value GLYPH_ORDER_KEY = PUBLIC_PREFIX + 'glyphOrder' @@ -51,6 +52,7 @@ def __init__(self, ufo_module=defcon, designspace_module=designSpaceDocument, family_name=None, + instance_dir=None, propagate_anchors=True, use_designspace=False, minimize_glyphs_diffs=False): @@ -65,6 +67,8 @@ def __init__(self, Document. Should look like designSpaceDocument. family_name -- if provided, the master UFOs will be given this name and only instances with this name will be returned. + instance_dir -- if provided, instance UFOs will be located in this + directory, according to their Designspace filenames. propagate_anchors -- set to False to prevent anchor propagation use_designspace -- set to True to make optimal use of the designspace: data that is common to all ufos will go there. @@ -75,16 +79,24 @@ def __init__(self, self.font = font self.ufo_module = ufo_module self.designspace_module = designspace_module + self.instance_dir = instance_dir self.propagate_anchors = propagate_anchors self.use_designspace = use_designspace self.minimize_glyphs_diffs = minimize_glyphs_diffs - # The set of UFOs (= defcon.Font objects) that will be built, + # The set of (SourceDescriptor + UFO)s that will be built, # indexed by master ID, the same order as masters in the source GSFont. - self._ufos = OrderedDict() + self._sources = OrderedDict() - # The designSpaceDocument object that will be built (if requested). - self._designspace = None + # The designSpaceDocument object that will be built. + # The sources will be built in any case, at the same time that we build + # the master UFOs, when the user requests them. + # The axes, instances, rules... will only be built if the designspace + # document itself is requested by the user. + self._designspace = self.designspace_module.DesignSpaceDocument( + writerClass=designSpaceDocument.InMemoryDocWriter, + fontClass=self.ufo_module.Font) + self._designspace_is_complete = False # check that source was generated with at least stable version 2.3 # https://github.com/googlei18n/glyphsLib/pull/65#issuecomment-237158140 @@ -114,8 +126,9 @@ def __init__(self, def masters(self): """Get an iterator over master UFOs that match the given family_name. """ - if self._ufos: - return self._ufos.values() + if self._sources: + for source in self._sources.values(): + yield source.font # Store set of actually existing master (layer) ids. This helps with # catching dangling layer data that Glyphs may ignore, e.g. when @@ -147,7 +160,7 @@ def masters(self): layer_name, layer)) continue - ufo = self._ufos[layer_id] + ufo = self._sources[layer_id].font ufo_glyph = ufo.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, glyph) ufo_layer = ufo.layers.defaultLayer @@ -175,7 +188,7 @@ def masters(self): 'be skipped.'.format(self.font.familyName, glyph_name)) continue - ufo_font = self._ufos[master_id] + ufo_font = self._sources[master_id].font if layer_name not in ufo_font.layers: ufo_layer = ufo_font.newLayer(layer_name) else: @@ -188,7 +201,8 @@ def masters(self): ufo_glyph = ufo_layer.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, layer.parent) - for ufo in self._ufos.values(): + for source in self._sources.values(): + ufo = source.font if self.propagate_anchors: self.to_ufo_propagate_font_anchors(ufo) self.to_ufo_features(ufo) # This depends on the glyphOrder key @@ -198,7 +212,8 @@ def masters(self): self.to_ufo_groups() self.to_ufo_kerning() - return self._ufos.values() + for source in self._sources.values(): + yield source.font def _layer_order_in_glyph(self, layer): # TODO: move to layers.py @@ -219,14 +234,24 @@ def designspace(self): """Get a designspace Document instance that links the masters together and holds instance data. """ - if self._designspace is not None: + if self._designspace_is_complete: return self._designspace - - self._designspace = self.designspace_module.DesignSpaceDocument( - writerClass=designSpaceDocument.InMemoryDocWriter, - fontClass=self.ufo_module.Font) + self._designspace_is_complete = True + ufos = list(self.masters) # Make sure that the UFOs are built + # FIXME: (jany) feels wrong + self.to_designspace_axes() + self.to_designspace_sources() self.to_designspace_instances() self.to_designspace_family_user_data() + + # append base style shared by all masters to designspace file name + base_family = self.font.familyName or 'Unnamed' + base_style = find_base_style(self.font.masters) + if base_style: + base_style = "-" + base_style + name = (base_family + base_style).replace(' ', '') + '.designspace' + self.designspace.filename = name + return self._designspace # DEPRECATED @@ -253,6 +278,7 @@ def instance_data(self): # Implementation is split into one file per feature from .anchors import to_ufo_propagate_font_anchors, to_ufo_glyph_anchors from .annotations import to_ufo_annotations + from .axes import to_designspace_axes from .background_image import to_ufo_background_image from .blue_values import to_ufo_blue_values from .common import to_ufo_time @@ -269,6 +295,7 @@ def instance_data(self): from .masters import to_ufo_master_attributes from .names import to_ufo_names from .paths import to_ufo_paths + from .sources import to_designspace_sources from .user_data import (to_designspace_family_user_data, to_ufo_family_user_data, to_ufo_master_user_data, to_ufo_glyph_user_data, to_ufo_layer_lib, @@ -299,6 +326,16 @@ def __init__(self, minimize_ufo_diffs=False): """Create a builder that goes from UFOs + designspace to Glyphs. + If you provide: + * Some UFOs, no designspace: the given UFOs will be combined. + No instance data will be created, only the weight and width + axes will be set up (if relevant). + * A designspace, no UFOs: the UFOs will be loaded according to + the designspace's sources. Instance and axis data will be + converted to Glyphs. + * Both a designspace and some UFOs: not supported for now. + TODO: find out whether there is a use-case here? + Keyword arguments: ufos -- The list of UFOs to combine into a GSFont designspace -- A MutatorMath Designspace to use for the GSFont @@ -318,24 +355,20 @@ def __init__(self, if designspace is not None: self.designspace = designspace if ufos: - # assert all(ufo in designspace.getFonts() for ufo in ufos) - self.ufos = ufos - else: - self.ufos = [] - for source in designspace.sources: - # FIXME: (jany) Do something better for the InMemory stuff - # Is it an in-memory source descriptor? - if not hasattr(source, 'font'): - if source.path: - source.font = designspace.fontClass(source.path) - else: - dirname = os.path.dirname(designspace.path) - ufo_path = os.path.join(dirname, source.filename) - source.font = designspace.fontClass(ufo_path) - self.ufos.append(source.font) + raise NotImplementedError + for source in designspace.sources: + # FIXME: (jany) Do something better for the InMemory stuff + # Is it an in-memory source descriptor? + if not hasattr(source, 'font'): + if source.path: + # FIXME: (jany) consider not mucking with the caller's objects + source.font = designspace.fontClass(source.path) + else: + dirname = os.path.dirname(designspace.path) + ufo_path = os.path.join(dirname, source.filename) + source.font = designspace.fontClass(ufo_path) elif ufos: self.designspace = self._fake_designspace(ufos) - self.ufos = ufos else: raise RuntimeError( 'Please provide a designspace or at least one UFO.') @@ -349,20 +382,20 @@ def font(self): if self._font is not None: return self._font - # Sort UFOS in the original order - self.to_glyphs_ordered_masters() + # Sort UFOS in the original order from the Glyphs file + sorted_sources = self.to_glyphs_ordered_masters() self._font = self.glyphs_module.GSFont() - self._ufos = OrderedDict() # Same as in UFOBuilder - for index, ufo in enumerate(self.ufos): + self._sources = OrderedDict() # Same as in UFOBuilder + for index, source in enumerate(sorted_sources): master = self.glyphs_module.GSFontMaster() - self.to_glyphs_font_attributes(ufo, master, + self.to_glyphs_font_attributes(source, master, is_initial=(index == 0)) - self.to_glyphs_master_attributes(ufo, master) + self.to_glyphs_master_attributes(source, master) self._font.masters.insert(len(self._font.masters), master) - self._ufos[master.id] = ufo + self._sources[master.id] = source - for layer in ufo.layers: + for layer in source.font.layers: self.to_glyphs_layer_lib(layer) for glyph in layer: self.to_glyphs_glyph(glyph, layer, master) @@ -371,23 +404,24 @@ def font(self): self.to_glyphs_kerning() # Now that all GSGlyph are built, restore the glyph order - for first_ufo in self.ufos: + if self.designspace.sources: + first_ufo = self.designspace.sources[0].font if GLYPH_ORDER_KEY in first_ufo.lib: glyph_order = first_ufo.lib[GLYPH_ORDER_KEY] lookup = {name: i for i, name in enumerate(glyph_order)} self.font.glyphs = sorted( self.font.glyphs, key=lambda glyph: lookup.get(glyph.name, 1 << 63)) - - # FIXME: (jany) Only do that on the first one. Maybe we should + # FIXME: (jany) We only do that on the first one. Maybe we should # merge the various `public.glyphorder` values? - break - # Restore the layer ordering in each glyph - for glyph in self._font.glyphs: - self.to_glyphs_layer_order(glyph) + # Restore the layer ordering in each glyph + for glyph in self._font.glyphs: + self.to_glyphs_layer_order(glyph) self.to_glyphs_family_user_data_from_designspace() + self.to_glyphs_axes() + self.to_glyphs_sources() self.to_glyphs_instances() return self._font @@ -399,6 +433,34 @@ def _fake_designspace(self, ufos): designspace = designSpaceDocument.DesignSpaceDocument( writerClass=designSpaceDocument.InMemoryDocWriter) + ufo_to_location = defaultdict(dict) + + # Make weight and width axis if relevant + for info_key, axis_def in zip( + ('openTypeOS2WeightClass', 'openTypeOS2WidthClass'), + DEFAULT_AXES_DEFS): + axis = designspace.newAxisDescriptor() + axis.tag = axis_def.tag + axis.name = axis_def.name + axis.labelNames = {"en": axis_def.name} + mapping = [] + for ufo in ufos: + user_loc = getattr(ufo.info, info_key) + if user_loc is not None: + design_loc = class_to_value(axis_def.tag, user_loc) + mapping.append((user_loc, design_loc)) + ufo_to_location[ufo][axis_def.name] = design_loc + + mapping = sorted(set(mapping)) + if len(mapping) > 1: + axis.map = mapping + axis.minimum = min([user_loc for user_loc, _ in mapping]) + axis.maximum = max([user_loc for user_loc, _ in mapping]) + axis.default = min(axis.maximum, + max(axis.minimum, + axis_def.default_user_loc)) + designspace.addAxis(axis) + for ufo in ufos: source = designspace.newSourceDescriptor() source.font = ufo @@ -406,34 +468,14 @@ def _fake_designspace(self, ufos): source.styleName = ufo.info.styleName # source.name = '%s %s' % (source.familyName, source.styleName) source.path = ufo.path - - # MutatorMath.DesignSpaceDocumentWriter iterates over the location - # dictionary, which is non-deterministic so it can cause test failures. - # We therefore use an OrderedDict to which we insert in axis order. - # Since glyphsLib will switch to DesignSpaceDocument once that is - # integrated into fonttools, it's not worth fixing upstream. - # https://github.com/googlei18n/glyphsLib/issues/165 - # FIXME: (jany) still needed? - # location = OrderedDict() - # for axis in self.designspace.axes: - # value_key = axis.name + 'Value' - # if axis.name.startswith('custom'): - # # FIXME: (jany) this is getting boring - # value_key = 'customValue' + axis.name[len('custom'):] - # location[axis.name] = ufo.lib.get( - # MASTER_CUSTOM_PARAM_PREFIX + value_key, DEFAULT_LOCS[axis.name]) - source.location = {} - # if font is regular: - # source.copyLib = True - # source.copyInfo = True - # source.copyGroups = True - # source.copyFeatures = True + source.location = ufo_to_location[ufo] designspace.addSource(source) return designspace # Implementation is split into one file per feature from .anchors import to_glyphs_glyph_anchors from .annotations import to_glyphs_annotations + from .axes import to_glyphs_axes from .background_image import to_glyphs_background_image from .blue_values import to_glyphs_blue_values from .components import (to_glyphs_components, @@ -451,6 +493,7 @@ def _fake_designspace(self, ufos): from .masters import to_glyphs_master_attributes from .names import to_glyphs_family_names, to_glyphs_master_names from .paths import to_glyphs_paths + from .sources import to_glyphs_sources from .user_data import (to_glyphs_family_user_data_from_designspace, to_glyphs_family_user_data_from_ufo, to_glyphs_master_user_data, diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index ec919d6ad..8c27063f6 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -53,7 +53,9 @@ def to_ufo_font_attributes(self, family_name): glyph_order = list(glyph.name for glyph in font.glyphs) for index, master in enumerate(font.masters): + source = self.designspace.newSourceDescriptor() ufo = self.ufo_module.Font() + source.font = ufo ufo.lib[APP_VERSION_LIB_KEY] = font.appVersion ufo.lib[KEYBOARD_INCREMENT_KEY] = font.keyboardIncrement @@ -81,14 +83,15 @@ def to_ufo_font_attributes(self, family_name): self.to_ufo_family_user_data(ufo) self.to_ufo_custom_params(ufo, font) - self.to_ufo_master_attributes(ufo, master) + self.to_ufo_master_attributes(source, master) ufo.lib[MASTER_ORDER_LIB_KEY] = index # FIXME: (jany) in the future, yield this UFO (for memory, lazy iter) - self._ufos[master.id] = ufo + self.designspace.addSource(source) + self._sources[master.id] = source -def to_glyphs_font_attributes(self, ufo, master, is_initial): +def to_glyphs_font_attributes(self, source, master, is_initial): """ Copy font attributes from `ufo` either to `self.font` or to `master`. @@ -104,14 +107,15 @@ def to_glyphs_font_attributes(self, ufo, master, is_initial): # modified in only one of the UFOs in a MM. Maybe do this check later, # when the roundtrip without modification works. if is_initial: - _set_glyphs_font_attributes(self, ufo) + _set_glyphs_font_attributes(self, source) else: # self._compare_and_merge_glyphs_font_attributes(ufo) pass -def _set_glyphs_font_attributes(self, ufo): +def _set_glyphs_font_attributes(self, source): font = self.font + ufo = source.font info = ufo.info if APP_VERSION_LIB_KEY in ufo.lib: @@ -147,14 +151,13 @@ def _set_glyphs_font_attributes(self, ufo): def to_glyphs_ordered_masters(self): - """Modify in-place the list of UFOs to restore their original order.""" - self.ufos = sorted(self.ufos, key=_original_master_order) + """Modify in-place the list of UFOs to restore their original order in + the Glyphs file (if any, otherwise does not change the order).""" + return sorted(self.designspace.sources, key=_original_master_order) -def _original_master_order(ufo): - # FIXME: (jany) Here we should rely on order of sources in designspace - # if self.use_designspace +def _original_master_order(source): try: - return ufo.lib[MASTER_ORDER_LIB_KEY] + return source.font.lib[MASTER_ORDER_LIB_KEY] except KeyError: - return float('infinity') + return 1 << 31 diff --git a/Lib/glyphsLib/builder/groups.py b/Lib/glyphsLib/builder/groups.py index a36f3cf3c..5a545c9a0 100644 --- a/Lib/glyphsLib/builder/groups.py +++ b/Lib/glyphsLib/builder/groups.py @@ -81,17 +81,17 @@ def to_ufo_groups(self): groups[group].append(glyph.name) # Update all UFOs with the same info - for ufo in self._ufos.values(): + for source in self._sources.values(): for name, glyphs in groups.items(): # Shallow copy to prevent unexpected object sharing - ufo.groups[name] = glyphs[:] + source.font.groups[name] = glyphs[:] def to_glyphs_groups(self): # Build the GSClasses from the groups of the first UFO. groups = [] - for ufo in self.ufos: - for name, glyphs in ufo.groups.items(): + for source in self._sources.values(): + for name, glyphs in source.font.groups.items(): if _is_kerning_group(name): _to_glyphs_kerning_group(self, name, glyphs) else: @@ -103,11 +103,11 @@ def to_glyphs_groups(self): break # Check that other UFOs are identical and print a warning if not. - for index, ufo in enumerate(self.ufos): + for index, source in enumerate(self._sources.values()): if index == 0: - reference_ufo = ufo + reference_ufo = source.font else: - _assert_groups_are_identical(self, reference_ufo, ufo) + _assert_groups_are_identical(self, reference_ufo, source.font) def _is_kerning_group(name): diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index b9fd50857..da19e4c67 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -16,253 +16,35 @@ unicode_literals) from collections import OrderedDict +import os from glyphsLib.util import build_ufo_path +from glyphsLib.classes import WEIGHT_CODES from .constants import (GLYPHS_PREFIX, GLYPHLIB_PREFIX, FONT_CUSTOM_PARAM_PREFIX, MASTER_CUSTOM_PARAM_PREFIX) from .names import build_stylemap_names from .masters import UFO_FILENAME_KEY +from .axes import get_axis_definitions, is_instance_active, interp EXPORT_KEY = GLYPHS_PREFIX + 'export' WIDTH_KEY = GLYPHS_PREFIX + 'width' WEIGHT_KEY = GLYPHS_PREFIX + 'weight' -WEIGHT_CLASS_KEY = GLYPHS_PREFIX + 'weightClass' -WIDTH_CLASS_KEY = GLYPHS_PREFIX + 'widthClass' +FULL_FILENAME_KEY = GLYPHLIB_PREFIX + 'fullFilename' MANUAL_INTERPOLATION_KEY = GLYPHS_PREFIX + 'manualInterpolation' INSTANCE_INTERPOLATIONS_KEY = GLYPHS_PREFIX + 'intanceInterpolations' def to_designspace_instances(self): """Write instance data from self.font to self.designspace.""" - - # base_family = masters[0].info.familyName - # assert all(m.info.familyName == base_family for m in masters), \ - # 'Masters must all have same family' - - # for font in masters: - # write_ufo(font, master_dir) - - # needed so that added masters and instances have correct relative paths - # tmp_path = os.path.join(master_dir, 'tmp.designspace') - # writer = DesignSpaceDocumentWriter(tmp_path) - - # instances = list(filter(is_instance_active, instance_data.get('data', []))) - ufo_masters = list(self.masters) - if ufo_masters: - varfont_origin = _get_varfont_origin(ufo_masters) - regular = _find_regular_master(ufo_masters, regularName=varfont_origin) - _to_designspace_axes(self, regular) - _to_designspace_sources(self, regular) - for instance in self.font.instances: - _to_designspace_instance(self, instance) - - -def _get_varfont_origin(masters): - # the 'Variation Font Origin' is a font-wide custom parameter, thus it is - # shared by all the master ufos; here we just get it from the first one - assert len(masters) > 0 - varfont_origin_key = "Variation Font Origin" - return masters[0].lib.get(FONT_CUSTOM_PARAM_PREFIX + varfont_origin_key) - - -def _find_regular_master(masters, regularName=None): - """Find the "regular" master among the master UFOs. - - Tries to find the master with the passed 'regularName'. - If there is no such master or if regularName is None, - tries to find a base style shared between all masters - (defaulting to "Regular"), and then tries to find a master - with that style name. If there is no master with that name, - returns the first master in the list. - """ - assert len(masters) > 0 - if regularName is not None: - for font in masters: - if font.info.styleName == regularName: - return font - base_style = find_base_style(masters) - if not base_style: - base_style = 'Regular' - for font in masters: - if font.info.styleName == base_style: - return font - return masters[0] - - -def find_base_style(masters): - """Find a base style shared between all masters. - Return empty string if none is found. - """ - base_style = masters[0].info.styleName.split() - for font in masters: - style = font.info.styleName.split() - base_style = [s for s in style if s in base_style] - base_style = ' '.join(base_style) - return base_style - - -# Glyphs.app's default values for the masters' {weight,width,custom}Value -# and for the instances' interpolation{Weight,Width,Custom} properties. -# When these values are set, they are omitted from the .glyphs source file. -# FIXME: (jany) This behaviour should be in classes.py -DEFAULT_LOCS = { - 'weight': 100, - 'width': 100, - 'custom': 0, - 'custom1': 0, - 'custom2': 0, - 'custom3': 0, -} - -WEIGHT_CODES = { - 'Thin': 250, - 'ExtraLight': 250, - 'UltraLight': 250, - 'Light': 300, - None: 400, # default value normally omitted in source - 'Normal': 400, - 'Regular': 400, - 'Medium': 500, - 'DemiBold': 600, - 'SemiBold': 600, - 'Bold': 700, - 'UltraBold': 800, - 'ExtraBold': 800, - 'Black': 900, - 'Heavy': 900, -} - -WIDTH_CODES = { - 'Ultra Condensed': 1, - 'Extra Condensed': 2, - 'Condensed': 3, - 'SemiCondensed': 4, - None: 5, # default value normally omitted in source - 'Medium (normal)': 5, - 'Semi Expanded': 6, - 'Expanded': 7, - 'Extra Expanded': 8, - 'Ultra Expanded': 9, -} - - -def _to_designspace_axes(self, regular_master): - # According to Georg Seifert, Glyphs 3 will have a better model - # for describing variation axes. The plan is to store the axis - # information globally in the Glyphs file. In addition to actual - # variation axes, this new structure will probably also contain - # stylistic information for design axes that are not variable but - # should still be stored into the OpenType STAT table. - # - # We currently take the minima and maxima from the instances, and - # have hard-coded the default value for each axis. We could be - # smarter: for the minima and maxima, we could look at the masters - # (whose locations are only stored in interpolation space, not in - # user space) and reverse-interpolate these locations to user space. - # Likewise, we could try to infer the default axis value from the - # masters. But it's probably not worth this effort, given that - # the upcoming version of Glyphs is going to store explicit - # axis desriptions in its file format. - - # FIXME: (jany) find interpolation data in GSFontMaster rather than in UFO? - # It would allow to drop the DEFAULT_LOCS dictionary - masters = list(self.masters) - instances = self.font.instances - - for name, tag, userLocParam, defaultUserLoc, codes in ( - ('weight', 'wght', 'weightClass', 400, WEIGHT_CODES), - ('width', 'wdth', 'widthClass', 100, WIDTH_CODES), - ('custom', 'XXXX', None, 0, None), - ('custom1', 'XXX1', None, 0, None), - ('custom2', 'XXX2', None, 0, None), - ('custom3', 'XXX3', None, 0, None)): - key = MASTER_CUSTOM_PARAM_PREFIX + name + 'Value' - if name.startswith('custom'): - key = MASTER_CUSTOM_PARAM_PREFIX + 'customValue' + name[len('custom'):] - if any(key in master.lib for master in masters): - axis = self.designspace.newAxisDescriptor() - axis.tag = tag - axis.name = name - regularInterpolLoc = regular_master.lib.get(key, DEFAULT_LOCS[name]) - regularUserLoc = defaultUserLoc - - labelName = name.title() - if name.startswith('custom'): - name_key = MASTER_CUSTOM_PARAM_PREFIX + 'customName' + name[len('custom'):] - for master in masters: - if name_key in master.lib: - labelName = master.lib[name_key] - break - axis.labelNames = {"en": labelName} - - interpolLocKey = name + 'Value' - if name.startswith('custom'): - interpolLocKey = 'customValue' + name[len('custom'):] - mapping = [] - for instance in instances: - interpolLoc = getattr(instance, interpolLocKey) - userLoc = interpolLoc - if userLocParam in instance.customParameters: - userLoc = float(instance.customParameters[userLocParam]) - elif (codes is not None and getattr(instance, name) and - getattr(instance, name) in codes): - userLoc = codes[getattr(instance, name)] - mapping.append((userLoc, interpolLoc)) - if interpolLoc == regularInterpolLoc: - regularUserLoc = userLoc - mapping = sorted(set(mapping)) # avoid duplicates - if mapping: - axis.minimum = min([userLoc for userLoc, _ in mapping]) - axis.maximum = max([userLoc for userLoc, _ in mapping]) - axis.default = min(axis.maximum, max(axis.minimum, regularUserLoc)) # clamp - else: - axis.minimum = axis.maximum = axis.default = defaultUserLoc - axis.map = mapping - self.designspace.addAxis(axis) - - -def _to_designspace_sources(self, regular): - """Add master UFOs to the designspace document.""" - # FIXME: (jany) maybe read data from the GSFontMasters directly? - for master, font in zip(self.font.masters, self.masters): - source = self.designspace.newSourceDescriptor() - source.font = font - source.familyName = font.info.familyName - source.styleName = font.info.styleName - source.name = '%s %s' % (source.familyName, source.styleName) - if UFO_FILENAME_KEY in master.userData: - source.filename = master.userData[UFO_FILENAME_KEY] - else: - source.filename = build_ufo_path('.', source.familyName, - source.styleName) - - # MutatorMath.DesignSpaceDocumentWriter iterates over the location - # dictionary, which is non-deterministic so it can cause test failures. - # We therefore use an OrderedDict to which we insert in axis order. - # Since glyphsLib will switch to DesignSpaceDocument once that is - # integrated into fonttools, it's not worth fixing upstream. - # https://github.com/googlei18n/glyphsLib/issues/165 - # FIXME: (jany) still needed? - location = OrderedDict() - for axis in self.designspace.axes: - value_key = axis.name + 'Value' - if axis.name.startswith('custom'): - # FIXME: (jany) this is getting boring - value_key = 'customValue' + axis.name[len('custom'):] - location[axis.name] = font.lib.get( - MASTER_CUSTOM_PARAM_PREFIX + value_key, DEFAULT_LOCS[axis.name]) - source.location = location - if font is regular: - source.copyLib = True - source.copyInfo = True - source.copyGroups = True - source.copyFeatures = True - self.designspace.addSource(source) + if is_instance_active(instance) or self.minimize_glyphs_diffs: + _to_designspace_instance(self, instance) def _to_designspace_instance(self, instance): ufo_instance = self.designspace.newInstanceDescriptor() + # FIXME: (jany) most of these customParameters are actually attributes, + # at least according to https://docu.glyphsapp.com/#fontName for p in instance.customParameters: param, value = p.name, p.value if param == 'familyName': @@ -271,55 +53,56 @@ def _to_designspace_instance(self, instance): # Glyphs uses "postscriptFontName", not "postScriptFontName" ufo_instance.postScriptFontName = value elif param == 'fileName': - ufo_instance.filename = value + '.ufo' + fname = value + '.ufo' + if self.instance_dir is not None: + fname = self.instance_dir + '/' + fname + ufo_instance.filename = fname + if ufo_instance.familyName is None: ufo_instance.familyName = self.family_name - ufo_instance.styleName = instance.name + + # TODO: investigate the possibility of storing a relative path in the + # `filename` custom parameter. If yes, drop the key below. + fname = instance.customParameters[FULL_FILENAME_KEY] + if fname is not None: + if self.instance_dir: + fname = self.instance_dir + '/' + os.path.basename(fname) + ufo_instance.filename = fname if not ufo_instance.filename: - ufo_instance.filename = build_ufo_path('.', ufo_instance.familyName, - ufo_instance.styleName) - # ofiles.append((ufo_path, instance)) - # MutatorMath.DesignSpaceDocumentWriter iterates over the location - # dictionary, which is non-deterministic so it can cause test failures. - # We therefore use an OrderedDict to which we insert in axis order. - # Since glyphsLib will switch to DesignSpaceDocument once that is - # integrated into fonttools, it's not worth fixing upstream. - # https://github.com/googlei18n/glyphsLib/issues/165 - # FIXME: (jany) still needed? - location = OrderedDict() - # FIXME: (jany) make a function for iterating axes and the related properties? - for axis in self.designspace.axes: - value_key = axis.name + 'Value' - if axis.name.startswith('custom'): - value_key = 'customValue' + axis.name[len('custom'):] - location[axis.name] = getattr(instance, value_key) + instance_dir = self.instance_dir or '.' + ufo_instance.filename = build_ufo_path( + instance_dir, ufo_instance.familyName, ufo_instance.styleName) + location = {} + for axis_def in get_axis_definitions(self.font): + location[axis_def.name] = axis_def.get_design_loc(instance) ufo_instance.location = location - ufo_instance.styleMapFamilyName, ufo_instance.styleMapStyleName = \ - build_stylemap_names( - family_name=ufo_instance.familyName, - style_name=ufo_instance.styleName, - is_bold=instance.isBold, - is_italic=instance.isItalic, - linked_style=instance.linkStyle, - ) - - ufo_instance.name = ' '.join((ufo_instance.familyName, - ufo_instance.styleName)) - - ufo_instance.lib[EXPORT_KEY] = instance.active - ufo_instance.lib[WEIGHT_KEY] = instance.weight - ufo_instance.lib[WIDTH_KEY] = instance.width - - if 'weightClass' in instance.customParameters: - ufo_instance.lib[WEIGHT_CLASS_KEY] = instance.customParameters['weightClass'] - if 'widthClass' in instance.customParameters: - ufo_instance.lib[WIDTH_CLASS_KEY] = instance.customParameters['widthClass'] - - ufo_instance.lib[INSTANCE_INTERPOLATIONS_KEY] = instance.instanceInterpolations - ufo_instance.lib[MANUAL_INTERPOLATION_KEY] = instance.manualInterpolation + # FIXME: (jany) should be the responsibility of ufo2ft? + # Anyway, only generate the styleMap names if the Glyphs instance already + # has a linkStyle set up, or if we're not round-tripping (i.e. generating + # UFOs for fontmake, the traditional use-case of glyphsLib.) + if instance.linkStyle or not self.minimize_glyphs_diffs: + ufo_instance.styleMapFamilyName, ufo_instance.styleMapStyleName = \ + build_stylemap_names( + family_name=ufo_instance.familyName, + style_name=ufo_instance.styleName, + is_bold=instance.isBold, + is_italic=instance.isItalic, + linked_style=instance.linkStyle, + ) + + ufo_instance.name = ' '.join((ufo_instance.familyName or '', + ufo_instance.styleName or '')) + + if self.minimize_glyphs_diffs: + ufo_instance.lib[EXPORT_KEY] = instance.active + ufo_instance.lib[WEIGHT_KEY] = instance.weight + ufo_instance.lib[WIDTH_KEY] = instance.width + + ufo_instance.lib[INSTANCE_INTERPOLATIONS_KEY] = instance.instanceInterpolations + ufo_instance.lib[MANUAL_INTERPOLATION_KEY] = instance.manualInterpolation # TODO: put the userData/customParameters in lib self.designspace.addInstance(ufo_instance) @@ -363,44 +146,69 @@ def to_glyphs_instances(self): instance.name = ufo_instance.styleName + for axis_def in get_axis_definitions(self.font): + design_loc = None + try: + design_loc = ufo_instance.location[axis_def.name] + axis_def.set_design_loc(instance, design_loc) + except KeyError: + # The location does not have this axis? + pass + + if axis_def.tag in ('wght', 'wdth'): + # Retrieve the user location (weightClass/widthClass) + # TODO: (jany) update comments + # First way: for UFOs/designspace of other origins, read + # the mapping backwards and check that the user location + # matches the instance's weight/width. If not, set the the + # custom param. + user_loc = design_loc + mapping = None + for axis in self.designspace.axes: + if axis.tag == axis_def.tag: + mapping = axis.map + if mapping: + reverse_mapping = [(dl, ul) for ul, dl in mapping] + user_loc = interp(reverse_mapping, design_loc) + if user_loc is not None: + axis_def.set_user_loc(instance, user_loc) + try: - instance.weight = ufo_instance.lib[WEIGHT_KEY] + # Restore the original weightClass when there is an ambiguity based + # on the value, e.g. Thin, ExtraLight, UltraLight all map to 250. + # No problem with width, because 1:1 mapping in WIDTH_CODES. + weight = ufo_instance.lib[WEIGHT_KEY] + if (not instance.weight or + WEIGHT_CODES[instance.weight] == WEIGHT_CODES[weight]): + instance.weight = weight except KeyError: # FIXME: what now pass try: - instance.width = ufo_instance.lib[WIDTH_KEY] + if not instance.width: + instance.width = ufo_instance.lib[WIDTH_KEY] except KeyError: # FIXME: what now pass - for axis in [ - 'weight', 'width', 'custom', 'custom1', 'custom2', 'custom3']: - # Retrieve the interpolation location - try: - loc = ufo_instance.location[axis] - value_key = axis + 'Value' - if axis.startswith('custom'): - value_key = 'customValue' + axis[len('custom'):] - setattr(instance, value_key, loc) - except KeyError: - # FIXME: (jany) what now? - pass + if ufo_instance.familyName is not None: + if ufo_instance.familyName != self.font.familyName: + instance.familyName = ufo_instance.familyName - for axis, lib_key in [('weight', WEIGHT_CLASS_KEY), - ('width', WIDTH_CLASS_KEY)]: - # Retrieve the user location (weightClass/widthClass) - try: - # First way: for round-tripped data, read the glyphsLib key - instance.customParameters[axis + 'Class'] = ufo_instance.lib[ - lib_key] - except KeyError: - # Second way: for UFOs/designspace of other origins, read the - # mapping backwards and check that the user location matches - # the instance's weight/width. If not, set the the custom param. - # TODO: (jany) - pass + smfn = ufo_instance.styleMapFamilyName + if smfn is not None: + if smfn.startswith(ufo_instance.familyName): + smfn = smfn[len(ufo_instance.familyName):].strip() + instance.linkStyle = smfn + + if ufo_instance.styleMapStyleName is not None: + style = ufo_instance.styleMapStyleName + instance.isBold = ('bold' in style) + instance.isItalic = ('italic' in style) + + if ufo_instance.postScriptFontName is not None: + instance.fontName = ufo_instance.postScriptFontName try: instance.manualInterpolation = ufo_instance.lib[ @@ -416,4 +224,9 @@ def to_glyphs_instances(self): # if instance.manualInterpolation: warn about data loss pass - self.font.instances.append(instance) + if self.minimize_ufo_diffs: + instance.customParameters[ + FULL_FILENAME_KEY] = ufo_instance.filename + + # FIXME: (jany) cannot `.append()` because no proxy => no parent + self.font.instances = self.font.instances + [instance] diff --git a/Lib/glyphsLib/builder/kerning.py b/Lib/glyphsLib/builder/kerning.py index afb4f2fe9..7581afe0c 100644 --- a/Lib/glyphsLib/builder/kerning.py +++ b/Lib/glyphsLib/builder/kerning.py @@ -22,7 +22,7 @@ def to_ufo_kerning(self): for master_id, kerning in self.font.kerning.items(): - _to_ufo_kerning(self, self._ufos[master_id], kerning) + _to_ufo_kerning(self, self._sources[master_id].font, kerning) def _to_ufo_kerning(self, ufo, kerning_data): @@ -99,8 +99,8 @@ def _remove_rule_if_conflict(self, ufo, seen, classname, glyph, is_left_class): def to_glyphs_kerning(self): """Add UFO kerning to GSFont.""" - for master_id, ufo in self._ufos.items(): - for (left, right), value in ufo.kerning.items(): + for master_id, source in self._sources.items(): + for (left, right), value in source.font.kerning.items(): left_match = UFO_KERN_GROUP_PATTERN.match(left) right_match = UFO_KERN_GROUP_PATTERN.match(right) if left_match: diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index 3ec94e0ca..3d4c323a2 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -15,8 +15,8 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -import uuid import os +from collections import OrderedDict from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX @@ -27,7 +27,8 @@ UFO_NOTE_KEY = GLYPHLIB_PREFIX + 'ufoNote' -def to_ufo_master_attributes(self, ufo, master): +def to_ufo_master_attributes(self, source, master): + ufo = source.font ufo.info.ascender = master.ascender ufo.info.capHeight = master.capHeight ufo.info.descender = master.descender @@ -87,13 +88,17 @@ def to_ufo_master_attributes(self, ufo, master): ufo.lib[MASTER_ID_LIB_KEY] = master_id -def to_glyphs_master_attributes(self, ufo, master): +def to_glyphs_master_attributes(self, source, master): + ufo = source.font try: master.id = ufo.lib[MASTER_ID_LIB_KEY] except KeyError: - master.id = str(uuid.uuid4()) + # GSFontMaster has a random id by default + pass - if ufo.path and self.minimize_ufo_diffs: + if source.filename is not None and self.minimize_ufo_diffs: + master.userData[UFO_FILENAME_KEY] = source.filename + elif ufo.path and self.minimize_ufo_diffs: master.userData[UFO_FILENAME_KEY] = os.path.basename(ufo.path) master.ascender = ufo.info.ascender @@ -120,108 +125,8 @@ def to_glyphs_master_attributes(self, ufo, master): if ufo.info.note is not None: master.userData[UFO_NOTE_KEY] = ufo.info.note - # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 - source = _get_designspace_source_for_ufo(self, ufo) - for axis in ['weight', 'width']: - attr = 'openTypeOS2%sClass' % axis.capitalize() - ufo_class = getattr(ufo.info, attr) - # First, try the designspace - try: - # TODO: ??? name = source.lib[...] - # TODO: maybe handled by names.py? - # setattr(master, axis, name) - raise KeyError - except KeyError: - # Second, try the custom key - try: - setattr(master, axis, ufo.lib[GLYPHS_PREFIX + axis]) - except KeyError: - if ufo_class: - setattr(master, axis, _class_to_name(axis, ufo_class)) - - value_key = axis + 'Value' - # First, try the designspace - try: - loc = source.location[axis] - setattr(master, value_key, loc) - except KeyError: - # Second, try the custom key - try: - setattr(master, value_key, ufo.lib[GLYPHS_PREFIX + value_key]) - except KeyError: - if ufo_class: - setattr(master, value_key, _class_to_value( - axis, ufo_class)) - - for number in ('', '1', '2', '3'): - # For the custom locations, we need both the name and the value - # FIXME: (jany) not sure it's worth implementing if everything is going - # to change soon on Glyphs.app's side. - pass - # try: - # axis = 'custom' + number - # value_key = 'customValue' + number - # loc = source.location[axis] - # value_key = axis + 'Value' - # if axis.startswith('custom'): - # setattr(instance, value_key, loc) - # except KeyError: - # pass - - # name_key = GLYPHS_PREFIX + 'customName' + number - # if name_key in ufo.lib: - # custom_name = ufo.lib[name_key] - # if custom_name: - # setattr(master, 'customName' + number, custom_name) - # value_key = GLYPHS_PREFIX + 'customValue' + number - # if value_key in ufo.lib: - # custom_value = ufo.lib[value_key] - # if custom_value: - # setattr(master, 'customValue' + number, custom_value) - self.to_glyphs_blue_values(ufo, master) self.to_glyphs_master_names(ufo, master) self.to_glyphs_master_user_data(ufo, master) self.to_glyphs_guidelines(ufo, master) self.to_glyphs_custom_params(ufo, master) - - -def _get_designspace_source_for_ufo(self, ufo): - for source in self.designspace.sources: - if source.font == ufo: - return source - -# FIXME: (jany) this code/data must also be somewhere else, refactor -# From the spec: https://www.microsoft.com/typography/otspec/os2.htm#wtc -CLASSES_DICT = { - 'weight': { - 100: ('Thin', 100), - 200: ('Extra-light', 200), - 300: ('Light', 300), - 400: ('Regular', 400), - 500: ('Medium', 500), - 600: ('Semi-bold', 600), - 700: ('Bold', 700), - 800: ('Extra-bold', 800), - 900: ('Black', 900), - }, - 'width': { - 1: ('Ultra-condensed', 50), - 2: ('Extra-condensed', 62.5), - 3: ('Condensed', 75), - 4: ('Semi-condensed', 87.5), - 5: ('Medium', 100), - 6: ('Semi-expanded', 112.5), - 7: ('Expanded', 125), - 8: ('Extra-expanded', 150), - 9: ('Ultra-expanded', 200), - } -} - - -def _class_to_name(axis, ufo_class): - return CLASSES_DICT[axis][ufo_class][0] - - -def _class_to_value(axis, ufo_class): - return CLASSES_DICT[axis][ufo_class][1] diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index ca6f67c3f..9a0280987 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -24,12 +24,13 @@ def to_ufo_names(self, ufo, master, family_name): custom = master.customName is_italic = bool(master.italicAngle) - styleName = build_style_name( - width if width != 'Regular' else '', + styleName = master.name or build_style_name( + width if width != 'Medium (normal)' else '', weight if weight != 'Regular' else '', custom, is_italic ) + # FIXME: (jany) should be the responsibility of ufo2ft? styleMapFamilyName, styleMapStyleName = build_stylemap_names( family_name=family_name, style_name=styleName, @@ -65,7 +66,7 @@ def build_stylemap_names(family_name, style_name, is_bold=False, if not linked_style or linked_style == 'Regular': linked_style = _get_linked_style(style_name, is_bold, is_italic) if linked_style: - styleMapFamilyName = family_name + ' ' + linked_style + styleMapFamilyName = (family_name or '') + ' ' + linked_style else: styleMapFamilyName = family_name return styleMapFamilyName, styleMapStyleName @@ -99,29 +100,20 @@ def _get_linked_style(style_name, is_bold, is_italic): def to_glyphs_family_names(self, ufo): - # FIXME: (jany) dubious, the ufo family name is not what was in Glyphs but - # what was given as an argument to to_ufo... why? self.font.familyName = ufo.info.familyName def to_glyphs_master_names(self, ufo, master): - # One way would be to split the `ufo.info.styleName` - # and find out for each part whether it is a width, weight or customName - - # Instead we shove all of it into custom, unless we can already build the - # stylename with the currently available info in the master. - # TODO: more testing of this width = master.width weight = master.weight custom = master.customName is_italic = bool(master.italicAngle) current_stylename = build_style_name( - width if width != 'Regular' else '', + width if width != 'Medium (normal)' else '', weight if weight != 'Regular' else '', custom, is_italic ) - if current_stylename != ufo.info.styleName: - master.customName = ufo.info.styleName + master.name = ufo.info.styleName diff --git a/Lib/glyphsLib/builder/sources.py b/Lib/glyphsLib/builder/sources.py new file mode 100644 index 000000000..c53654321 --- /dev/null +++ b/Lib/glyphsLib/builder/sources.py @@ -0,0 +1,79 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import (print_function, division, absolute_import, + unicode_literals) + +import os + +from glyphsLib.util import build_ufo_path + +from .masters import UFO_FILENAME_KEY +from .axes import get_axis_definitions, get_regular_master + + +def to_designspace_sources(self): + regular_master = get_regular_master(self.font) + for master in self.font.masters: + _to_designspace_source(self, master, (master is regular_master)) + + +def _to_designspace_source(self, master, is_regular): + source = self._sources[master.id] + ufo = source.font + + if is_regular: + source.copyLib = True + source.copyInfo = True + source.copyGroups = True + source.copyFeatures = True + + source.familyName = ufo.info.familyName + source.styleName = ufo.info.styleName + # TODO: recover original source name from userData + # UFO_SOURCE_NAME_KEY + source.name = '%s %s' % (source.familyName, source.styleName) + + # TODO: (jany) make sure to use forward slashes? Maybe it should be the + # responsibility of DesignspaceDocument + if UFO_FILENAME_KEY in master.userData: + source.filename = master.userData[UFO_FILENAME_KEY] + else: + # TODO: (jany) allow another naming convention? + source.filename = os.path.basename( + # FIXME: (jany) have this function not write the dot + build_ufo_path('.', source.familyName, source.styleName)) + + location = {} + for axis_def in get_axis_definitions(self.font): + location[axis_def.name] = axis_def.get_design_loc(master) + source.location = location + + +def to_glyphs_sources(self): + for master in self.font.masters: + _to_glyphs_source(self, master) + + +def _to_glyphs_source(self, master): + source = self._sources[master.id] + + # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 + for axis_def in get_axis_definitions(self.font): + try: + axis_def.set_design_loc(master, source.location[axis_def.name]) + except KeyError: + # The location does not have this axis? + pass + diff --git a/Lib/glyphsLib/builder/user_data.py b/Lib/glyphsLib/builder/user_data.py index 63d09a3bc..b011a457c 100644 --- a/Lib/glyphsLib/builder/user_data.py +++ b/Lib/glyphsLib/builder/user_data.py @@ -30,7 +30,9 @@ def to_designspace_family_user_data(self): if self.use_designspace: - self.designspace.lib.update(dict(self.font.userData)) + for key, value in dict(self.font.userData).items(): + if _user_data_has_no_special_meaning(key): + self.designspace.lib[key] = value def to_ufo_family_user_data(self, ufo): diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index c8d4d8f48..12bdc3dda 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -150,6 +150,38 @@ RTLTTB = 2 +WEIGHT_CODES = { + 'Thin': 250, + 'ExtraLight': 250, + 'UltraLight': 250, + 'Light': 300, + None: 400, # default value normally omitted in source + 'Normal': 400, + 'Regular': 400, + 'Medium': 500, + 'DemiBold': 600, + 'SemiBold': 600, + 'Bold': 700, + 'UltraBold': 800, + 'ExtraBold': 800, + 'Black': 900, + 'Heavy': 900, +} + +WIDTH_CODES = { + 'Ultra Condensed': 1, + 'Extra Condensed': 2, + 'Condensed': 3, + 'SemiCondensed': 4, + None: 5, # default value normally omitted in source + 'Medium (normal)': 5, + 'Semi Expanded': 6, + 'Expanded': 7, + 'Extra Expanded': 8, + 'Ultra Expanded': 9, +} + + class OnlyInGlyphsAppError(NotImplementedError): def __init__(self): NotImplementedError.__init__(self, "This property/method is only available in the real UI-based version of Glyphs.app.") @@ -1193,7 +1225,9 @@ class GSFontMaster(GSBase): "iconName": str, "id": str, "italicAngle": float, - "name": unicode, + "name": unicode, # FIXME: (jany) does not seem to be filled in by + # Glyphs 1113, instead chops up the name into + # weight or custom. "userData": dict, "verticalStems": int, "visible": bool, @@ -1204,10 +1238,17 @@ class GSFontMaster(GSBase): "xHeight": float, } _defaultsForName = { + # FIXME: (jany) In the latest Glyphs (1113), masters don't have a width + # and weight anymore as attributes, even though those properties are + # still written to the saved files. "weight": "Regular", - "width": "Regular", + "width": "Medium (normal)", "weightValue": 100.0, "widthValue": 100.0, + "customValue": 0.0, + "customValue1": 0.0, + "customValue2": 0.0, + "customValue3": 0.0, "xHeight": 500, "capHeight": 700, "ascender": 800, @@ -1252,6 +1293,7 @@ class GSFontMaster(GSBase): def __init__(self): super(GSFontMaster, self).__init__() + self.id = str(uuid.uuid4()) self.font = None self._name = None self._customParameters = [] @@ -1266,10 +1308,10 @@ def __repr__(self): (self.name, self.widthValue, self.weightValue) def shouldWriteValueForKey(self, key): - if key in ("width", "weight"): - if getattr(self, key) == "Regular": - return False - return True + if key == "width": + return getattr(self, key) != "Medium (normal)" + if key == "weight": + return getattr(self, key) != "Regular" if key in ("xHeight", "capHeight", "ascender", "descender"): # Always write those values return True @@ -1281,34 +1323,42 @@ def shouldWriteValueForKey(self, key): @property def name(self): - # FIXME: (jany) this getter looks stupid, it never returns the value - # from self._name. TODO: test what Glyphs does and how this makes sense - name = self.customParameters["Master Name"] - if name is None: - names = [self.weight, self.width] - for number in ('', '1', '2', '3'): - custom_name = getattr(self, 'customName' + number) - if (custom_name and len(custom_name) and - custom_name not in names): - names.append(custom_name) - - if len(names) > 1 and "Regular" in names: - names.remove("Regular") - - if abs(self.italicAngle) > 0.01: - names.append("Italic") - name = " ".join(list(names)) - self._name = name - return name + name = self.customParameters['Master Name'] + if name: + return name + if self._name: + return self._name + return self._joinName() @name.setter def name(self, value): - # FIXME: (jany) this is called during init while there are no - # customparameters defined yet and it crashes, so I added the if - # because during init it sets an empty string - if value: - self._name = value - # self.customParameters["Master Name"] = value + # This is what Glyphs 1113 seems to be doing, approximately. + self.weight, self.width, self.customName = self._splitName(value) + + def _joinName(self): + names = [self.weight, self.width, self.customName] + names = [n for n in names if n] # Remove None and empty string + if len(names) > 1 and "Medium (normal)" in names: + names.remove("Medium (normal)") + if len(names) > 1 and "Regular" in names: + names.remove("Regular") + if abs(self.italicAngle) > 0.01: + names.append("Italic") + return " ".join(list(names)) + + def _splitName(self, value): + if value is None: + value = '' + weight = 'Regular' + width = 'Medium (normal)' + custom = '' + names = value.split(" ") + if names and names[0] in WEIGHT_CODES: + weight = names.pop(0) + if names and names[0] in WIDTH_CODES: + witdh = names.pop(0) + custom = " ".join(names) + return (weight, width, custom) customParameters = property( lambda self: CustomParametersProxy(self), @@ -2246,8 +2296,6 @@ def __init__(self): self.visible = True self.isBold = False self.isItalic = False - self.widthClass = "Medium (normal)" - self.weightClass = "Regular" self._customParameters = [] customParameters = property( @@ -2272,7 +2320,7 @@ def familyName(self): @familyName.setter def familyName(self, value): - self.customParameters["famiyName"] = value + self.customParameters["familyName"] = value @property def preferredFamily(self): @@ -3003,6 +3051,7 @@ def masterForId(self, key): return master return None + # FIXME: (jany) Why is this not a FontInstanceProxy? @property def instances(self): return self._instances diff --git a/Lib/glyphsLib/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py index 84b1519f6..ffc0bf301 100644 --- a/Lib/glyphsLib/designSpaceDocument.py +++ b/Lib/glyphsLib/designSpaceDocument.py @@ -111,8 +111,8 @@ class SourceDescriptor(SimpleDescriptor): def __init__(self): self.document = None # a reference to the parent document - self.filename = None # the original path as found in the document - self.path = None # the absolute path, calculated from filename + self._filename = None # the original path as found in the document + self._path = None # the absolute path, calculated from filename self.name = None self.location = None self.copyLib = False @@ -125,6 +125,28 @@ def __init__(self): self.familyName = None self.styleName = None + @property + def filename(self): + return self._filename + + @filename.setter + def filename(self, value): + if value is None: + self._filename = None + return + self._filename = posixpath.join(*value.split(os.path.sep)) + + @property + def path(self): + return self._path + + @path.setter + def path(self, value): + if value is None: + self._path = None + return + self._path = posixpath.join(*value.split(os.path.sep)) + class RuleDescriptor(SimpleDescriptor): """ @@ -211,8 +233,8 @@ class InstanceDescriptor(SimpleDescriptor): 'lib'] def __init__(self): - self.filename = None # the original path as found in the document - self.path = None # the absolute path, calculated from filename + self._filename = None # the original path as found in the document + self._path = None # the absolute path, calculated from filename self.name = None self.location = None self.familyName = None @@ -230,6 +252,28 @@ def __init__(self): self.info = True self.lib = {} + @property + def filename(self): + return self._filename + + @filename.setter + def filename(self, value): + if value is None: + self._filename = None + return + self._filename = posixpath.join(*value.split(os.path.sep)) + + @property + def path(self): + return self._path + + @path.setter + def path(self, value): + if value is None: + self._path = None + return + self._path = posixpath.join(*value.split(os.path.sep)) + def setStyleName(self, styleName, languageCode="en"): self.localisedStyleName[languageCode] = styleName def getStyleName(self, languageCode="en"): @@ -1013,6 +1057,7 @@ class DesignSpaceDocument(object): def __init__(self, readerClass=None, writerClass=None, fontClass=None): self.logger = logging.getLogger("DesignSpaceDocumentLog") self.path = None + self.filename = None # A preferred filename for the document self.formatVersion = None self.sources = [] self.instances = [] @@ -1048,6 +1093,7 @@ def write(self, path): writer.write() def _posixRelativePath(self, otherPath): + # FIXME: (jany) not needed anymore thanks to the descriptor accessors? relative = os.path.relpath(otherPath, os.path.dirname(self.path)) return posixpath.join(*relative.split(os.path.sep)) diff --git a/Lib/glyphsLib/util.py b/Lib/glyphsLib/util.py index 557a894c5..da3f9f4c1 100644 --- a/Lib/glyphsLib/util.py +++ b/Lib/glyphsLib/util.py @@ -27,8 +27,8 @@ def build_ufo_path(out_dir, family_name, style_name): return os.path.join( out_dir, '%s-%s.ufo' % ( - family_name.replace(' ', ''), - style_name.replace(' ', ''))) + (family_name or '').replace(' ', ''), + (style_name or '').replace(' ', ''))) def write_ufo(ufo, out_dir): diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index 70f148e60..a3183fe64 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -764,7 +764,7 @@ def test_lib_weight(self): def test_lib_no_width(self): font = generate_minimal_font() ufo = to_ufos(font)[0] - self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'width'], 'Regular') + self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'width'], 'Medium (normal)') def test_lib_width(self): font = generate_minimal_font() diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py new file mode 100644 index 000000000..37003df59 --- /dev/null +++ b/tests/builder/interpolation_test.py @@ -0,0 +1,560 @@ +# coding=UTF-8 +# +# Copyright 2017 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import (print_function, division, absolute_import, + unicode_literals) +import difflib +import os.path +import shutil +import sys +import tempfile +import unittest +import xml.etree.ElementTree as etree + +# import defcon +from fontTools.misc.py23 import open +from glyphsLib.builder.constants import GLYPHS_PREFIX +# from glyphsLib.builder.interpolation import ( +# build_designspace, set_weight_class, set_width_class, build_stylemap_names +# ) +from glyphsLib.classes import GSFont, GSFontMaster, GSInstance +from glyphsLib import to_designspace, to_glyphs + + +# Current limitation of glyphsLib for designspace to designspace round-trip: +# the designspace's axes, sources and instances must be as such: +# - the axes' min, max must match extreme masters +# - the axes' default must match the "regular master" +# - the axes' mapping must have as many pairs as instances, each pair +# matching the (userLoc, designLoc) of an instance. If there are no +# instances, same requirement but with 1 pair/master +# +# Basically this is to say that the designspace must have been generated by +# glyphsLib in the first place. +# +# More general designspaces (like: axes that go farther than extreme masters, +# mapping with arbitrary numbers of pairs...) might be supported later, if/when +# Glyphs gets a UI to setup this information. +# +# REVIEW: check that the above makes sense + + +def makeFamily(): + m1 = makeMaster("Regular", weight=90.0) + m2 = makeMaster("Black", weight=190.0) + instances = [ + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Semibold", weight=("SemiBold", 600, 128)), + makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), + makeInstance("Black", weight=("Black", 900, 190)), + ] + return [m1, m2], instances + + +def makeMaster(styleName, weight=None, width=None): + m = GSFontMaster() + m.name = styleName + if weight is not None: + m.weightValue = weight + if width is not None: + m.widthValue = width + return m + + +def makeInstance(name, weight=None, width=None, is_bold=None, is_italic=None, + linked_style=None): + inst = GSInstance() + inst.name = name + if weight is not None: + # Glyphs 2.5 stores the instance weight in two to three places: + # 1. as a textual weight (such as “Bold”; no value defaults to + # "Regular"); + # 2. (optional) as numeric customParameters.weightClass (such as 700), + # which corresponds to OS/2.usWeightClass where 100 means Thin, + # 400 means Regular, 700 means Bold, and 900 means Black; + # 3. as numeric weightValue (such as 66.0), which typically is + # the stem width but can be anything that works for interpolation + # (no value defaults to 100). + weightName, weightClass, interpolationWeight = weight + if weightName is not None: + inst.weight = weightName + if weightClass is not None: + inst.customParameters["weightClass"] = weightClass + if interpolationWeight is not None: + inst.weightValue = interpolationWeight + if width is not None: + # Glyphs 2.5 stores the instance width in two to three places: + # 1. as a textual width (such as “Condensed”; no value defaults + # to "Medium (normal)"); + # 2. (optional) as numeric customParameters.widthClass (such as 5), + # which corresponds to OS/2.usWidthClass where 1 means Ultra- + # condensed, 5 means Medium (normal), and 9 means Ultra-expanded; + # 3. as numeric widthValue (such as 79), which typically is + # a percentage of whatever the font designer considers “normal” + # but can be anything that works for interpolation (no value + # defaults to 100). + widthName, widthClass, interpolationWidth = width + if widthName is not None: + inst.width = widthName + if widthClass is not None: + inst.customParameters["widthClass"] = widthClass + if interpolationWidth is not None: + inst.widthValue = interpolationWidth + # TODO: Support custom axes; need to triple-check how these are encoded in + # Glyphs files. Glyphs 3 will likely overhaul the representation of axes. + if is_bold is not None: + inst.isBold = is_bold + if is_italic is not None: + inst.isItalic = is_italic + if linked_style is not None: + inst.linkStyle = linked_style + return inst + + +def makeFont(masters, instances, familyName): + font = GSFont() + font.familyName = familyName + font.masters = masters + font.instances = instances + return font + + +class DesignspaceTest(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def write_to_tmp_path(self, doc, name): + path = os.path.join(self.tmpdir, name) + doc.write(path) + return path + + def expect_designspace(self, doc, expected_name): + dirname = os.path.dirname(__file__) + expected_path = os.path.join(dirname, "..", "data", expected_name) + return self._expect_designspace(doc, expected_path) + + def _expect_designspace(self, doc, expected_path): + actual_path = self.write_to_tmp_path(doc, 'generated.designspace') + with open(actual_path, mode="r", encoding="utf-8") as f: + actual = f.readlines() + with open(expected_path, mode="r", encoding="utf-8") as f: + expected = f.readlines() + if actual != expected: + expected_name = os.path.basename(expected_path) + for line in difflib.unified_diff( + expected, actual, + fromfile=expected_name, tofile=""): + sys.stderr.write(line) + self.fail("*.designspace file is different from expected") + + def expect_designspace_roundtrip(self, doc): + actual_path = self.write_to_tmp_path(doc, 'original.designspace') + font = to_glyphs(doc, minimize_ufo_diffs=True) + rtdoc = to_designspace(font) + return self._expect_designspace(rtdoc, actual_path) + + def test_basic(self): + masters, instances = makeFamily() + font = makeFont(masters, instances, "DesignspaceTest Basic") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestBasic.designspace") + self.expect_designspace_roundtrip(doc) + + def test_inactive_from_exports(self): + # Glyphs.app recognizes exports=0 as a flag for inactive instances. + # https://github.com/googlei18n/glyphsLib/issues/129 + masters, instances = makeFamily() + for inst in instances: + if inst.name != "Semibold": + inst.exports = False + font = makeFont(masters, instances, "DesignspaceTest Inactive") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestInactive.designspace") + self.expect_designspace_roundtrip(doc) + + def test_familyName(self): + masters, _ = makeFamily() + customFamily = makeInstance("Regular", weight=("Bold", 600, 151)) + customFamily.customParameters["familyName"] = "Custom Family" + instances = [ + makeInstance("Regular", weight=("Regular", 400, 90)), + customFamily, + ] + font = makeFont(masters, instances, "DesignspaceTest FamilyName") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestFamilyName.designspace") + self.expect_designspace_roundtrip(doc) + + def test_fileName(self): + masters, _ = makeFamily() + customFileName = makeInstance("Regular", weight=("Bold", 600, 151)) + customFileName.customParameters["fileName"] = "Custom FileName" + instances = [ + makeInstance("Regular", weight=("Regular", 400, 90)), + customFileName, + ] + font = makeFont(masters, instances, "DesignspaceTest FamilyName") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestFileName.designspace") + self.expect_designspace_roundtrip(doc) + + def test_noRegularMaster(self): + # Currently, fonttools.varLib fails to build variable fonts + # if the default axis value does not happen to be at the + # location of one of the interpolation masters. + # glyhpsLib tries to work around this downstream limitation. + masters = [ + makeMaster("Thin", weight=26), + makeMaster("Black", weight=190), + ] + instances = [ + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Bold", weight=("Thin", 100, 26)), + ] + font = makeFont(masters, instances, "NoRegularMaster") + designspace = to_designspace(font, instance_dir='out') + path = self.write_to_tmp_path(designspace, 'noregular.designspace') + doc = etree.parse(path) + weightAxis = doc.find('axes/axis[@tag="wght"]') + self.assertEqual(weightAxis.attrib["minimum"], "100") + self.assertEqual(weightAxis.attrib["default"], "100") # not 400 + self.assertEqual(weightAxis.attrib["maximum"], "900") + + self.expect_designspace_roundtrip(designspace) + + def test_postscriptFontName(self): + master = makeMaster("Master") + thin, black = makeInstance("Thin"), makeInstance("Black") + black.customParameters["postscriptFontName"] = "PSNameTest-Superfat" + font = makeFont([master], [thin, black], "PSNameTest") + designspace = to_designspace(font, instance_dir='out') + path = self.write_to_tmp_path(designspace, 'psname.designspace') + d = etree.parse(path) + + def psname(doc, style): + inst = doc.find('instances/instance[@stylename="%s"]' % style) + return inst.attrib.get('postscriptfontname') + self.assertIsNone(psname(d, "Thin")) + self.assertEqual(psname(d, "Black"), "PSNameTest-Superfat") + + self.expect_designspace_roundtrip(designspace) + + def test_instanceOrder(self): + # The generated *.designspace file should place instances + # in the same order as they appear in the original source. + # https://github.com/googlei18n/glyphsLib/issues/113 + masters, _ = makeFamily() + instances = [ + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), + ] + font = makeFont(masters, instances, "DesignspaceTest InstanceOrder") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, + "DesignspaceTestInstanceOrder.designspace") + self.expect_designspace_roundtrip(doc) + + def test_twoAxes(self): + # In NotoSansArabic-MM.glyphs, the regular width only contains + # parameters for the weight axis. For the width axis, glyphsLib + # should use 100 as default value (just like Glyphs.app does). + familyName = "DesignspaceTest TwoAxes" + masters = [ + makeMaster("Regular", weight=90), + makeMaster("Black", weight=190), + makeMaster("Thin", weight=26), + makeMaster("ExtraCond", weight=90, width=70), + makeMaster("ExtraCond Black", weight=190, width=70), + makeMaster("ExtraCond Thin", weight=26, width=70), + ] + instances = [ + makeInstance("Thin", weight=("Thin", 100, 26)), + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Semibold", weight=("SemiBold", 600, 128)), + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("ExtraCondensed Thin", + weight=("Thin", 100, 26), + width=("Extra Condensed", 2, 70)), + makeInstance("ExtraCondensed", + weight=("Regular", 400, 90), + width=("Extra Condensed", 2, 70)), + makeInstance("ExtraCondensed Black", + weight=("Black", 900, 190), + width=("Extra Condensed", 2, 70)), + ] + font = makeFont(masters, instances, familyName) + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestTwoAxes.designspace") + self.expect_designspace_roundtrip(doc) + + def test_variationFontOrigin(self): + # Glyphs 2.4.1 introduced a custom parameter “Variation Font Origin” + # to specify which master should be considered the origin. + # https://glyphsapp.com/blog/glyphs-2-4-1-released + masters = [ + makeMaster("Thin", weight=26), + makeMaster("Regular", weight=100), + makeMaster("Medium", weight=111), + makeMaster("Black", weight=190), + ] + instances = [ + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("Medium", weight=("Medium", 444, 111)), + makeInstance("Regular", weight=("Regular", 400, 100)), + makeInstance("Thin", weight=("Thin", 100, 26)), + ] + font = makeFont(masters, instances, "Family") + font.customParameters["Variation Font Origin"] = "Medium" + designspace = to_designspace(font, instance_dir='out') + path = self.write_to_tmp_path(designspace, 'varfontorig.designspace') + doc = etree.parse(path) + medium = doc.find('sources/source[@stylename="Medium"]') + self.assertEqual(medium.find("lib").attrib["copy"], "1") + weightAxis = doc.find('axes/axis[@tag="wght"]') + self.assertEqual(weightAxis.attrib["default"], "444") + + self.expect_designspace_roundtrip(designspace) + + def test_designspace_name(self): + doc = to_designspace(makeFont([ + makeMaster("Regular", weight=100), + makeMaster("Bold", weight=190), + ], [], "Family Name")) + # no shared base style name, only write the family name + self.assertEqual(doc.filename, "FamilyName.designspace") + + doc = to_designspace(makeFont([ + makeMaster("Italic", weight=100), + makeMaster("Bold Italic", weight=190), + ], [], "Family Name")) + # 'Italic' is the base style; append to designspace name + self.assertEqual(doc.filename, "FamilyName-Italic.designspace") + + +# def test_designspace_roundtrip(tmpdir): +# return +# doc = DesignSpaceDocument() +# +# weight = doc.newAxisDescriptor() +# weight.minimum = 1 +# weight.maximum = 1000 +# weight.default = 400 +# weight.name = "weight" +# weight.tag = "wght" +# weight.labelNames['fa-IR'] = "قطر" +# weight.labelNames['en'] = "Wéíght" +# weight.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] +# doc.addAxis(weight) +# +# # No width axis (to check that a default one is not created) +# +# # One custom axis +# craziness = doc.newAxisDescriptor() +# craziness.minimum = -0.5 +# craziness.maximum = 0.5 +# craziness.default = 0 +# craziness.name = 'craziness' +# craziness.tag = 'CRZY' +# craziness.labelNames['en'] = 'Craziness' +# craziness.map = [] +# doc.addAxis(craziness) +# +# # Sources +# +# light_narrow = doc.newSourceDescriptor() +# light_narrow.font = defcon.Font() +# light_narrow.filename = 'sources/Cool Font LtNw.ufo' +# light_narrow.name = 'light_narrow' +# light_narrow.copyLib = True +# light_narrow.copyInfo = True +# light_narrow.copyFeatures = True +# light_narrow.location = dict( +# weight=1, # FIXME: should it be 1 or 10? +# craziness=0) +# light_narrow.familyName = "Cool Font" +# light_narrow.styleName = "Narrow Light" +# light_narrow.mutedGlyphNames.append("A") +# light_narrow.mutedGlyphNames.append("Z") +# doc.addSource(light_narrow) +# +# bold_narrow = doc.newSourceDescriptor() +# bold_narrow.font = defcon.Font() +# bold_narrow.filename = 'sources/Cool Font BdNw.ufo' +# bold_narrow.name = 'bold_narrow' +# bold_narrow.location = dict( +# weight=990, # FIXME: Should it be 1000 or 990? +# craziness=0) +# bold_narrow.familyName = "Cool Font" +# bold_narrow.styleName = "Narrow Bold" +# doc.addSource(bold_narrow) +# +# light_narrow_crazy = doc.newSourceDescriptor() +# light_narrow_crazy.font = defcon.Font() +# light_narrow_crazy.filename = 'sources/Cool Font BdNw.ufo' +# light_narrow_crazy.name = 'light_narrow' +# light_narrow_crazy.location = dict( +# weight=1, # FIXME: should it be 1 or 10? +# craziness=0.5) +# light_narrow_crazy.familyName = "Cool Font" +# light_narrow_crazy.styleName = "Narrow Bold" +# doc.addSource(light_narrow_crazy) +# +# # A source with mostly blank attributes to check that it does not crash +# out_of_place = doc.newSourceDescriptor() +# out_of_place.font = defcon.Font() +# doc.addSource(out_of_place) +# +# # Instances +# +# # TODO +# +# font = to_glyphs(doc) +# +# # TODO: check how stuff is stored for Glyphs +# +# rtdoc = to_designspace(font) +# +# # Compare +# path = os.path.join(str(tmpdir), 'original.designspace') +# doc.write(path) +# with open(path) as fp: +# xml = fp.read() +# rtpath = os.path.join(str(tmpdir), 'rt.designspace') +# rtdoc.write(rtpath) +# with open(rtpath) as fp: +# rtxml = fp.read() +# +# assert xml == rtxml +# +# +# WEIGHT_CLASS_KEY = GLYPHS_PREFIX + "weightClass" +# WIDTH_CLASS_KEY = GLYPHS_PREFIX + "widthClass" +# +# +# class SetWeightWidthClassesTest(unittest.TestCase): +# +# def test_no_weigth_class(self): +# ufo = defcon.Font() +# # name here says "Bold", however no excplit weightClass +# # is assigned +# set_weight_class(ufo, makeInstance("Bold")) +# # the default OS/2 weight class is set +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") +# +# def test_weight_class(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Bold", +# weight=("Bold", None, 150) +# ) +# +# set_weight_class(ufo, data) +# +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") +# +# def test_explicit_default_weight(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Regular", +# weight=("Regular", None, 100) +# ) +# +# set_weight_class(ufo, data) +# # the default OS/2 weight class is set +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") +# +# def test_no_width_class(self): +# ufo = defcon.Font() +# # no explicit widthClass set, instance name doesn't matter +# set_width_class(ufo, makeInstance("Normal")) +# # the default OS/2 width class is set +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") +# +# def test_width_class(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Condensed", +# width=("Condensed", 80) +# ) +# +# set_width_class(ufo, data) +# +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") +# +# def test_explicit_default_width(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Regular", +# width=("Medium (normal)", 100) +# ) +# +# set_width_class(ufo, data) +# # the default OS/2 width class is set +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") +# +# def test_weight_and_width_class(self): +# ufo = defcon.Font() +# data = makeInstance( +# "SemiCondensed ExtraBold", +# weight=("ExtraBold", None, 160), +# width=("SemiCondensed", 90) +# ) +# +# set_weight_class(ufo, data) +# set_width_class(ufo, data) +# +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 800) +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "ExtraBold") +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 4) +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") +# +# def test_unknown_weight_class(self): +# ufo = defcon.Font() +# # "DemiLight" is not among the predefined weight classes listed in +# # Glyphs.app/Contents/Frameworks/GlyphsCore.framework/Versions/A/ +# # Resources/weights.plist +# # NOTE It is not possible from the user interface to set a custom +# # string as instance 'weightClass' since the choice is constrained +# # by a drop-down menu. +# data = makeInstance( +# "DemiLight Italic", +# weight=("DemiLight", 350, 70) +# ) +# +# set_weight_class(ufo, data) +# +# # we do not set any OS/2 weight class; user needs to provide +# # a 'weightClass' custom parameter in this special case +# self.assertTrue(ufo.info.openTypeOS2WeightClass is None) + + +if __name__ == "__main__": + sys.exit(unittest.main()) diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 0fe9512fc..963346b92 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -273,7 +273,8 @@ def test_bad_ufo_date_format_in_glyph_lib(): def test_have_default_interpolation_values(): - """When no designspace is provided, make sure that the Glyphs file has some default "axis positions" for the masters. + """When no designspace is provided, make sure that the Glyphs file has some + default "axis positions" for the masters. """ thin = defcon.Font() thin.info.openTypeOS2WidthClass = 5 diff --git a/tests/classes_test.py b/tests/classes_test.py index ea2c27f61..d432cd9fd 100755 --- a/tests/classes_test.py +++ b/tests/classes_test.py @@ -568,17 +568,20 @@ def test_name(self): master.customName = 'Rounded' self.assertEqual('Light Rounded', master.name) - master.customName1 = 'Stretched' - master.customName2 = 'Filled' - master.customName3 = 'Rotated' + master.customName = 'Rounded Stretched Filled Rotated' self.assertEqual('Light Rounded Stretched Filled Rotated', master.name) - master.customName1 = '' - master.customName2 = '' - self.assertEqual('Light Rounded Rotated', master.name) master.customName = '' - master.customName3 = '' self.assertEqual('Light', master.name) + def test_default_values(self): + master = GSFontMaster() + self.assertEqual(master.weightValue, 100.0) + self.assertEqual(master.widthValue, 100.0) + self.assertEqual(master.customValue, 0.0) + self.assertEqual(master.customValue1, 0.0) + self.assertEqual(master.customValue2, 0.0) + self.assertEqual(master.customValue3, 0.0) + class GSAlignmentZoneFromFileTest(GSObjectsTestCase): @@ -700,6 +703,15 @@ def test_attributes(self): # TODO generate() + def test_default_values(self): + instance = GSInstance() + self.assertEqual(instance.weightValue, 100.0) + self.assertEqual(instance.widthValue, 100.0) + self.assertEqual(instance.customValue, 0.0) + self.assertEqual(instance.customValue1, 0.0) + self.assertEqual(instance.customValue2, 0.0) + self.assertEqual(instance.customValue3, 0.0) + class GSGlyphFromFileTest(GSObjectsTestCase): diff --git a/tests/data/DesignspaceTestBasic.designspace b/tests/data/DesignspaceTestBasic.designspace index f4e058a7b..385494239 100644 --- a/tests/data/DesignspaceTestBasic.designspace +++ b/tests/data/DesignspaceTestBasic.designspace @@ -1,12 +1,12 @@ - - - - - + Weight + + + + @@ -16,43 +16,43 @@ - + - + - + - + - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestFamilyName.designspace b/tests/data/DesignspaceTestFamilyName.designspace index 8ff5a4090..04564d1e7 100644 --- a/tests/data/DesignspaceTestFamilyName.designspace +++ b/tests/data/DesignspaceTestFamilyName.designspace @@ -1,10 +1,10 @@ - - - + Weight + + @@ -14,29 +14,29 @@ - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestFileName.designspace b/tests/data/DesignspaceTestFileName.designspace index 02031ff6b..60c683cad 100644 --- a/tests/data/DesignspaceTestFileName.designspace +++ b/tests/data/DesignspaceTestFileName.designspace @@ -1,10 +1,10 @@ - - - + Weight + + @@ -14,29 +14,29 @@ - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestInactive.designspace b/tests/data/DesignspaceTestInactive.designspace index f7ffd626b..e99eb4420 100644 --- a/tests/data/DesignspaceTestInactive.designspace +++ b/tests/data/DesignspaceTestInactive.designspace @@ -1,9 +1,9 @@ - - + Weight + @@ -13,22 +13,22 @@ - + - + - + - + diff --git a/tests/data/DesignspaceTestInstanceOrder.designspace b/tests/data/DesignspaceTestInstanceOrder.designspace index 1600debd0..69f920d4d 100644 --- a/tests/data/DesignspaceTestInstanceOrder.designspace +++ b/tests/data/DesignspaceTestInstanceOrder.designspace @@ -1,11 +1,11 @@ - - - - + Weight + + + @@ -15,36 +15,36 @@ - + - + - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestTwoAxes.designspace b/tests/data/DesignspaceTestTwoAxes.designspace index 9d858f89c..b2c4a6052 100644 --- a/tests/data/DesignspaceTestTwoAxes.designspace +++ b/tests/data/DesignspaceTestTwoAxes.designspace @@ -1,17 +1,17 @@ - - - - - + Weight + + + + - + + Width - Width @@ -21,97 +21,97 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + diff --git a/tests/interpolation_test.py b/tests/interpolation_test.py deleted file mode 100644 index 63aa75969..000000000 --- a/tests/interpolation_test.py +++ /dev/null @@ -1,408 +0,0 @@ -# coding=UTF-8 -# -# Copyright 2017 Google Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import (print_function, division, absolute_import, - unicode_literals) -import difflib -import os.path -import shutil -import sys -import tempfile -import unittest -import xml.etree.ElementTree as etree - -import defcon -from fontTools.misc.py23 import open -from glyphsLib.builder.constants import GLYPHS_PREFIX -from glyphsLib.builder.interpolation import ( - build_designspace, set_weight_class, set_width_class, build_stylemap_names -) -from glyphsLib.classes import GSInstance, GSCustomParameter - - -def makeFamily(familyName): - m1 = makeMaster(familyName, "Regular", weight=90.0) - m2 = makeMaster(familyName, "Black", weight=190.0) - instances = { - "data": [ - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Semibold", weight=("Semibold", 600, 128)), - makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), - makeInstance("Black", weight=("Black", 900, 190)), - ], - } - return [m1, m2], instances - - -def makeMaster(familyName, styleName, weight=None, width=None): - m = defcon.Font() - m.info.familyName, m.info.styleName = familyName, styleName - if weight is not None: - m.lib[GLYPHS_PREFIX + "weightValue"] = weight - if width is not None: - m.lib[GLYPHS_PREFIX + "widthValue"] = width - return m - - -def makeInstance(name, weight=None, width=None, is_bold=None, is_italic=None, - linked_style=None): - inst = GSInstance() - inst.name = name - if weight is not None: - # Glyphs 2.3 stores the instance weight in two to three places: - # 1. as a textual weightClass (such as “Bold”; no value defaults to - # "Regular"); - # 2. (optional) as numeric customParameters.weightClass (such as 700), - # which corresponds to OS/2.usWeightClass where 100 means Thin, - # 400 means Regular, 700 means Bold, and 900 means Black; - # 3. as numeric interpolationWeight (such as 66.0), which typically is - # the stem width but can be anything that works for interpolation - # (no value defaults to 100). - weightName, weightClass, interpolationWeight = weight - if weightName is not None: - inst.weightClass = weightName - if weightClass is not None: - inst.customParameters["weightClass"] = weightClass - if interpolationWeight is not None: - inst.interpolationWeight = interpolationWeight - if width is not None: - # Glyphs 2.3 stores the instance width in two places: - # 1. as a textual widthClass (such as “Condensed”; no value defaults - # to "Medium (normal)"); - # 2. as numeric interpolationWidth (such as 79), which typically is - # a percentage of whatever the font designer considers “normal” - # but can be anything that works for interpolation (no value - # defaults to 100). - widthClass, interpolationWidth = width - if widthClass is not None: - inst.widthClass = widthClass - if interpolationWidth is not None: - inst.interpolationWidth = interpolationWidth - # TODO: Support custom axes; need to triple-check how these are encoded in - # Glyphs files. Glyphs 3 will likely overhaul the representation of axes. - if is_bold is not None: - inst.isBold = is_bold - if is_italic is not None: - inst.isItalic = is_italic - if linked_style is not None: - inst.linkStyle = linked_style - return inst - - -class DesignspaceTest(unittest.TestCase): - def build_designspace(self, masters, instances): - master_dir = tempfile.mkdtemp() - try: - designspace, _ = build_designspace( - masters, master_dir, os.path.join(master_dir, "out"), instances) - with open(designspace, mode="r", encoding="utf-8") as f: - result = f.readlines() - finally: - shutil.rmtree(master_dir) - return result - - def expect_designspace(self, masters, instances, expectedFile): - actual = self.build_designspace(masters, instances) - path, _ = os.path.split(__file__) - expectedPath = os.path.join(path, "data", expectedFile) - with open(expectedPath, mode="r", encoding="utf-8") as f: - expected = f.readlines() - if actual != expected: - for line in difflib.unified_diff( - expected, actual, - fromfile=expectedPath, tofile=""): - sys.stderr.write(line) - self.fail("*.designspace file is different from expected") - - def test_basic(self): - masters, instances = makeFamily("DesignspaceTest Basic") - self.expect_designspace(masters, instances, - "DesignspaceTestBasic.designspace") - - def test_inactive_from_exports(self): - # Glyphs.app recognizes exports=0 as a flag for inactive instances. - # https://github.com/googlei18n/glyphsLib/issues/129 - masters, instances = makeFamily("DesignspaceTest Inactive") - for inst in instances["data"]: - if inst.name != "Semibold": - inst.exports = False - self.expect_designspace(masters, instances, - "DesignspaceTestInactive.designspace") - - def test_familyName(self): - masters, instances = makeFamily("DesignspaceTest FamilyName") - customFamily = makeInstance("Regular", weight=("Bold", 600, 151)) - customFamily.customParameters["familyName"] = "Custom Family" - instances["data"] = [ - makeInstance("Regular", weight=("Regular", 400, 90)), - customFamily, - ] - self.expect_designspace(masters, instances, - "DesignspaceTestFamilyName.designspace") - - def test_fileName(self): - masters, instances = makeFamily("DesignspaceTest FamilyName") - customFileName= makeInstance("Regular", weight=("Bold", 600, 151)) - customFileName.customParameters["fileName"] = "Custom FileName" - instances["data"] = [ - makeInstance("Regular", weight=("Regular", 400, 90)), - customFileName, - ] - self.expect_designspace(masters, instances, - "DesignspaceTestFileName.designspace") - - def test_noRegularMaster(self): - # Currently, fonttools.varLib fails to build variable fonts - # if the default axis value does not happen to be at the - # location of one of the interpolation masters. - # glyhpsLib tries to work around this downstream limitation. - masters = [ - makeMaster("NoRegularMaster", "Thin", weight=26), - makeMaster("NoRegularMaster", "Black", weight=190), - ] - instances = {"data": [ - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Bold", weight=("Thin", 100, 26)), - ]} - doc = etree.fromstringlist(self.build_designspace(masters, instances)) - weightAxis = doc.find('axes/axis[@tag="wght"]') - self.assertEqual(weightAxis.attrib["minimum"], "100.0") - self.assertEqual(weightAxis.attrib["default"], "100.0") # not 400 - self.assertEqual(weightAxis.attrib["maximum"], "900.0") - - def test_postscriptFontName(self): - master = makeMaster("PSNameTest", "Master") - thin, black = makeInstance("Thin"), makeInstance("Black") - instances = {"data": [thin, black]} - black.customParameters["postscriptFontName"] = "PSNameTest-Superfat" - d = etree.fromstringlist(self.build_designspace([master], instances)) - - def psname(doc, style): - inst = doc.find('instances/instance[@stylename="%s"]' % style) - return inst.attrib.get('postscriptfontname') - self.assertIsNone(psname(d, "Thin")) - self.assertEqual(psname(d, "Black"), "PSNameTest-Superfat") - - def test_instanceOrder(self): - # The generated *.designspace file should place instances - # in the same order as they appear in the original source. - # https://github.com/googlei18n/glyphsLib/issues/113 - masters, instances = makeFamily("DesignspaceTest InstanceOrder") - instances["data"] = [ - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), - ] - - self.expect_designspace(masters, instances, - "DesignspaceTestInstanceOrder.designspace") - - def test_twoAxes(self): - # In NotoSansArabic-MM.glyphs, the regular width only contains - # parameters for the weight axis. For the width axis, glyphsLib - # should use 100 as default value (just like Glyphs.app does). - familyName = "DesignspaceTest TwoAxes" - masters = [ - makeMaster(familyName, "Regular", weight=90), - makeMaster(familyName, "Black", weight=190), - makeMaster(familyName, "Thin", weight=26), - makeMaster(familyName, "ExtraCond", weight=90, width=70), - makeMaster(familyName, "ExtraCond Black", weight=190, width=70), - makeMaster(familyName, "ExtraCond Thin", weight=26, width=70), - ] - instances = { - "data": [ - makeInstance("Thin", weight=("Thin", 100, 26)), - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Semibold", weight=("Semibold", 600, 128)), - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("ExtraCondensed Thin", - weight=("Thin", 100, 26), - width=("Extra Condensed", 70)), - makeInstance("ExtraCondensed", - weight=("Regular", 400, 90), - width=("Extra Condensed", 70)), - makeInstance("ExtraCondensed Black", - weight=("Black", 900, 190), - width=("Extra Condensed", 70)), - ] - } - self.expect_designspace(masters, instances, - "DesignspaceTestTwoAxes.designspace") - - def test_variationFontOrigin(self): - # Glyphs 2.4.1 introduced a custom parameter “Variation Font Origin” - # to specify which master should be considered the origin. - # https://glyphsapp.com/blog/glyphs-2-4-1-released - masters = [ - makeMaster("Family", "Thin", weight=26), - makeMaster("Family", "Regular", weight=100), - makeMaster("Family", "Medium", weight=111), - makeMaster("Family", "Black", weight=190), - ] - instances = { - "data": [ - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("Medium", weight=("Medium", 444, 111)), - makeInstance("Regular", weight=("Regular", 400, 100)), - makeInstance("Thin", weight=("Thin", 100, 26)), - ], - "Variation Font Origin": "Medium", - } - doc = etree.fromstringlist(self.build_designspace(masters, instances)) - medium = doc.find('sources/source[@stylename="Medium"]') - self.assertEqual(medium.find("lib").attrib["copy"], "1") - weightAxis = doc.find('axes/axis[@tag="wght"]') - self.assertEqual(weightAxis.attrib["default"], "444.0") - - def test_designspace_name(self): - master_dir = tempfile.mkdtemp() - try: - designspace_path, _ = build_designspace( - [ - makeMaster("Family Name", "Regular", weight=100), - makeMaster("Family Name", "Bold", weight=190), - ], master_dir, os.path.join(master_dir, "out"), {}) - # no shared base style name, only write the family name - self.assertEqual(os.path.basename(designspace_path), - "FamilyName.designspace") - - designspace_path, _ = build_designspace( - [ - makeMaster("Family Name", "Italic", weight=100), - makeMaster("Family Name", "Bold Italic", weight=190), - ], master_dir, os.path.join(master_dir, "out"), {}) - # 'Italic' is the base style; append to designspace name - self.assertEqual(os.path.basename(designspace_path), - "FamilyName-Italic.designspace") - finally: - shutil.rmtree(master_dir) - - -WEIGHT_CLASS_KEY = GLYPHS_PREFIX + "weightClass" -WIDTH_CLASS_KEY = GLYPHS_PREFIX + "widthClass" - - -class SetWeightWidthClassesTest(unittest.TestCase): - - def test_no_weigth_class(self): - ufo = defcon.Font() - # name here says "Bold", however no excplit weightClass - # is assigned - set_weight_class(ufo, makeInstance("Bold")) - # the default OS/2 weight class is set - self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") - - def test_weight_class(self): - ufo = defcon.Font() - data = makeInstance( - "Bold", - weight=("Bold", None, 150) - ) - - set_weight_class(ufo, data) - - self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") - - def test_explicit_default_weight(self): - ufo = defcon.Font() - data = makeInstance( - "Regular", - weight=("Regular", None, 100) - ) - - set_weight_class(ufo, data) - # the default OS/2 weight class is set - self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") - - def test_no_width_class(self): - ufo = defcon.Font() - # no explicit widthClass set, instance name doesn't matter - set_width_class(ufo, makeInstance("Normal")) - # the default OS/2 width class is set - self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") - - def test_width_class(self): - ufo = defcon.Font() - data = makeInstance( - "Condensed", - width=("Condensed", 80) - ) - - set_width_class(ufo, data) - - self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") - - def test_explicit_default_width(self): - ufo = defcon.Font() - data = makeInstance( - "Regular", - width=("Medium (normal)", 100) - ) - - set_width_class(ufo, data) - # the default OS/2 width class is set - self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") - - def test_weight_and_width_class(self): - ufo = defcon.Font() - data = makeInstance( - "SemiCondensed ExtraBold", - weight=("ExtraBold", None, 160), - width=("SemiCondensed", 90) - ) - - set_weight_class(ufo, data) - set_width_class(ufo, data) - - self.assertEqual(ufo.info.openTypeOS2WeightClass, 800) - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "ExtraBold") - self.assertEqual(ufo.info.openTypeOS2WidthClass, 4) - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") - - def test_unknown_weight_class(self): - ufo = defcon.Font() - # "DemiLight" is not among the predefined weight classes listed in - # Glyphs.app/Contents/Frameworks/GlyphsCore.framework/Versions/A/ - # Resources/weights.plist - # NOTE It is not possible from the user interface to set a custom - # string as instance 'weightClass' since the choice is constrained - # by a drop-down menu. - data = makeInstance( - "DemiLight Italic", - weight=("DemiLight", 350, 70) - ) - - set_weight_class(ufo, data) - - # we do not set any OS/2 weight class; user needs to provide - # a 'weightClass' custom parameter in this special case - self.assertTrue(ufo.info.openTypeOS2WeightClass is None) - - -if __name__ == "__main__": - sys.exit(unittest.main()) diff --git a/tests/writer_test.py b/tests/writer_test.py index f9ec57070..dc74e659b 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -419,7 +419,7 @@ def test_write_instance(self): { customParameters = ( { - name = famiyName; + name = familyName; value = "Sans Rien (familyName)"; }, {