diff --git a/.gitignore b/.gitignore index 6c72109dd..f0876ad6f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,12 @@ dist # Unit test .cache/ .tox/ -.coverage +.coverage* htmlcov # Autosaved files *~ -# Additional test files -# (there's a script to download them from GitHub) -tests/noto-source* +# Files generated by tests +actual* +expected* diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index e6c473231..5f2dfc102 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -17,18 +17,19 @@ unicode_literals) from io import open +import os import logging from fontTools.misc.py23 import tostr -from glyphsLib.builder import to_ufos -from glyphsLib.interpolation import interpolate, build_designspace -from glyphsLib.parser import load, loads -from glyphsLib.writer import dump, dumps -from glyphsLib.util import write_ufo - from glyphsLib.classes import __all__ as __all_classes__ from glyphsLib.classes import * +from glyphsLib.builder import to_ufos, to_designspace, to_glyphs +from glyphsLib.builder.instances import InstanceData +from glyphsLib.interpolation import interpolate +from glyphsLib.parser import load, loads +from glyphsLib.writer import dump, dumps +from glyphsLib.util import clean_ufo __version__ = "2.2.2.dev0" @@ -76,16 +77,29 @@ def build_masters(filename, master_dir, designspace_instance_dir=None, paths from the designspace and respective data from the Glyphs source. """ - ufos, instance_data = load_to_ufos( - filename, include_instances=True, family_name=family_name, - propagate_anchors=propagate_anchors) + font = GSFont(filename) + instance_dir = None + if designspace_instance_dir is not None: + instance_dir = os.path.relpath(designspace_instance_dir, master_dir) + designspace = to_designspace( + font, family_name=family_name, propagate_anchors=propagate_anchors, + instance_dir=instance_dir) + ufos = [] + for source in designspace.sources: + ufos.append(source.font) + ufo_path = os.path.join(master_dir, source.filename) + clean_ufo(ufo_path) + source.font.save(ufo_path) + if designspace_instance_dir is not None: - designspace_path, instance_data = build_designspace( - ufos, master_dir, designspace_instance_dir, instance_data) - return ufos, designspace_path, instance_data + designspace_path = os.path.join(master_dir, designspace.filename) + designspace.write(designspace_path) + # All the instance data should be in the designspace. That's why for + # now we return the full designspace in place of `instance_data`. + # However, other functions still expect the instance data to have + # a list of (path, something) tuples, hence the class `InstanceData`. + return ufos, designspace_path, InstanceData(designspace) else: - for ufo in ufos: - write_ufo(ufo, master_dir) return ufos diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index 019052132..ff863fb4f 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -17,14 +17,20 @@ from glyphsLib import classes import defcon +from fontTools.designspaceLib import DesignSpaceDocument + from .builders import UFOBuilder, GlyphsBuilder logger = logging.getLogger(__name__) -def to_ufos(font, include_instances=False, family_name=None, - propagate_anchors=True, ufo_module=defcon): - """Take .glyphs file data and load it into UFOs. +def to_ufos(font, + include_instances=False, + family_name=None, + propagate_anchors=True, + ufo_module=defcon, + minimize_glyphs_diffs=False): + """Take a GSFont object and convert it into one UFO per master. Takes in data as Glyphs.app-compatible classes, as documented at https://docu.glyphsapp.com/ @@ -38,7 +44,8 @@ def to_ufos(font, include_instances=False, family_name=None, font, ufo_module=ufo_module, family_name=family_name, - propagate_anchors=propagate_anchors) + propagate_anchors=propagate_anchors, + minimize_glyphs_diffs=minimize_glyphs_diffs) result = list(builder.masters) @@ -47,13 +54,62 @@ def to_ufos(font, include_instances=False, family_name=None, return result -def to_glyphs(ufos, designspace=None, glyphs_module=classes): +def to_designspace(font, + family_name=None, + instance_dir=None, + propagate_anchors=True, + ufo_module=defcon, + minimize_glyphs_diffs=False): + """Take a GSFont object and convert it into a Designspace Document + UFOS. + The UFOs are available as the attribute `font` of each SourceDescriptor of + the DesignspaceDocument: + + ufos = [source.font for source in designspace.sources] + + The designspace and the UFOs are not written anywhere by default, they + are all in-memory. If you want to write them to the disk, consider using + the `filename` attribute of the DesignspaceDocument and of its + SourceDescriptor as possible file names. + + Takes in data as Glyphs.app-compatible classes, as documented at + https://docu.glyphsapp.com/ + + If include_instances is True, also returns the parsed instance data. + + If family_name is provided, the master UFOs will be given this name and + only instances with this name will be returned. """ - Take a list of UFOs and combine them into a single .glyphs file. + builder = UFOBuilder( + 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) + return builder.designspace + + +def to_glyphs(ufos_or_designspace, + glyphs_module=classes, + minimize_ufo_diffs=False): + """ + Take a list of UFOs or a single DesignspaceDocument with attached UFOs + and converts it into a GSFont object. + + The GSFont object is in-memory, it's up to the user to write it to the disk + if needed. - This should be the inverse function of `to_ufos`, + This should be the inverse function of `to_ufos` and `to_designspace`, so we should have to_glyphs(to_ufos(font)) == font + and also to_glyphs(to_designspace(font)) == font """ - builder = GlyphsBuilder( - ufos, designspace=designspace, glyphs_module=glyphs_module) + if hasattr(ufos_or_designspace, 'sources'): + builder = GlyphsBuilder(designspace=ufos_or_designspace, + glyphs_module=glyphs_module, + minimize_ufo_diffs=minimize_ufo_diffs) + else: + builder = GlyphsBuilder(ufos=ufos_or_designspace, + glyphs_module=glyphs_module, + minimize_ufo_diffs=minimize_ufo_diffs) return builder.font diff --git a/Lib/glyphsLib/builder/anchors.py b/Lib/glyphsLib/builder/anchors.py index 4db779c84..9409fc145 100644 --- a/Lib/glyphsLib/builder/anchors.py +++ b/Lib/glyphsLib/builder/anchors.py @@ -18,6 +18,8 @@ from fontTools.misc.transform import Transform +from glyphsLib.types import Point + __all__ = ['to_ufo_propagate_font_anchors'] @@ -104,3 +106,12 @@ def to_ufo_glyph_anchors(self, glyph, anchors): x, y = anchor.position anchor_dict = {'name': anchor.name, 'x': x, 'y': y} glyph.appendAnchor(anchor_dict) + + +def to_glyphs_glyph_anchors(self, ufo_glyph, layer): + """Add UFO glif anchors to a GSLayer.""" + for ufo_anchor in ufo_glyph.anchors: + anchor = self.glyphs_module.GSAnchor() + anchor.name = ufo_anchor.name + anchor.position = Point(ufo_anchor.x, ufo_anchor.y) + layer.anchors.append(anchor) diff --git a/Lib/glyphsLib/builder/annotations.py b/Lib/glyphsLib/builder/annotations.py new file mode 100644 index 000000000..52abae0c2 --- /dev/null +++ b/Lib/glyphsLib/builder/annotations.py @@ -0,0 +1,59 @@ +# Copyright 2016 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 .constants import GLYPHS_PREFIX +from glyphsLib.types import Point + +LIB_KEY = GLYPHS_PREFIX + 'annotations' + + +def to_ufo_annotations(self, ufo_glyph, layer): + try: + value = layer.annotations + except KeyError: + return + annotations = [] + for an in list(value.values()): + annot = {} + for attr in ['angle', 'position', 'text', 'type', 'width']: + val = getattr(an, attr, None) + if attr == 'position' and val: + val = list(val) + if val: + annot[attr] = val + annotations.append(annot) + + if annotations: + ufo_glyph.lib[LIB_KEY] = annotations + + +def to_glyphs_annotations(self, ufo_glyph, layer): + if LIB_KEY not in ufo_glyph.lib: + return + + for annot in ufo_glyph.lib[LIB_KEY]: + annotation = self.glyphs_module.GSAnnotation() + for attr in ['angle', 'position', 'text', 'type', 'width']: + if attr in annot and annot[attr]: + if attr == 'position': + # annot['position'] can be either "{1, 2}" or (1, 2) + position = Point(annot['position']) + annotation.position = position + else: + setattr(annotation, attr, annot[attr]) + layer.annotations.append(annotation) diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py new file mode 100644 index 000000000..e50fe183e --- /dev/null +++ b/Lib/glyphsLib/builder/axes.py @@ -0,0 +1,456 @@ +# 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://docs.microsoft.com/en-gb/typography/opentype/spec/os2#uswidthclass +WIDTH_CLASS_TO_VALUE = { + 1: 50, # Ultra-condensed + 2: 62.5, # Extra-condensed + 3: 75, # Condensed + 4: 87.5, # Semi-condensed + 5: 100, # Medium + 6: 112.5, # Semi-expanded + 7: 125, # Expanded + 8: 150, # Extra-expanded + 9: 200, # Ultra-expanded +} + + +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) + elif axis == 'wdth': + return WIDTH_CLASS_TO_VALUE[int(ufo_class)] + + raise NotImplementedError + + +def _nospace_lookup(dict, key): + try: + return dict[key] + except KeyError: + # Even though the Glyphs UI strings are supposed to be fixed, + # some Noto files contain variants of them that have spaces. + key = ''.join(str(key).split()) + return dict[key] + + +def user_loc_string_to_value(axis_tag, user_loc): + """Go from Glyphs UI strings to user space location. + Returns None if the string is invalid. + + >>> user_loc_string_to_value('wght', 'ExtraLight') + 250 + >>> user_loc_string_to_value('wdth', 'SemiCondensed') + 87.5 + >>> user_loc_string_to_value('wdth', 'Clearly Not From Glyphs UI') + None + """ + if axis_tag == 'wght': + try: + value = _nospace_lookup(WEIGHT_CODES, user_loc) + except KeyError: + return None + return class_to_value('wght', value) + elif axis_tag == 'wdth': + try: + value = _nospace_lookup(WIDTH_CODES, user_loc) + except KeyError: + return None + return class_to_value('wdth', value) + + # Currently this function should only be called with a width or weight + raise NotImplementedError + + +def user_loc_value_to_class(axis_tag, user_loc): + """Return the OS/2 weight or width class that is closest to the provided + user location. For weight the user location is between 0 and 1000 and for + width it is a percentage. + + >>> user_loc_value_to_class('wght', 310) + 300 + >>> user_loc_value_to_class('wdth', 62) + 2 + """ + if axis_tag == 'wght': + return int(user_loc) + elif axis_tag == 'wdth': + return min(sorted(WIDTH_CLASS_TO_VALUE.items()), + key=lambda item: abs(item[1] - user_loc))[0] + + raise NotImplementedError + + +def user_loc_value_to_instance_string(axis_tag, user_loc): + """Return the Glyphs UI string (from the instance dropdown) that is + closest to the provided user location. + + >>> user_loc_value_to_string('wght', 430) + 'Regular' + >>> user_loc_value_to_string('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 + + 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)) + instance_mapping = sorted(set(instance_mapping)) # avoid duplicates + + master_mapping = [] + for master in self.font.masters: + designLoc = axis_def.get_design_loc(master) + # Glyphs masters don't have a user location + userLoc = designLoc + master_mapping.append((userLoc, designLoc)) + master_mapping = sorted(set(master_mapping)) + + # Prefer the instance-based mapping + mapping = instance_mapping or master_mapping + + regularDesignLoc = axis_def.get_design_loc(regular_master) + # Glyphs masters don't have a user location, so we compute it by + # looking at the axis mapping in reverse. + reverse_mapping = [(dl, ul) for ul, dl in mapping] + regularUserLoc = interp(reverse_mapping, regularDesignLoc) + + minimum = maximum = default = axis_def.default_user_loc + 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 width 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): + """Centralize the code that deals with axis locations, user location versus + design location, associated OS/2 table codes, etc. + """ + + 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, glyphs_master_or_instance): + """Get the design location (aka interpolation value) of a Glyphs + master or instance along this axis. For example for the weight + axis it could be the thickness of a stem, for the width a percentage + of extension with respect to the normal width. + """ + return getattr(glyphs_master_or_instance, self.design_loc_key) + + def set_design_loc(self, master_or_instance, value): + """Set the design location of a Glyphs master or instance.""" + setattr(master_or_instance, self.design_loc_key, value) + + def get_user_loc(self, instance): + """Get the user location of a Glyphs instance. + Masters in Glyphs don't have a user location. + The user location is what the user sees on the slider in his + variable-font-enabled UI. For weight it is a value between 0 and 1000, + 400 being Regular and 700 Bold. + For width it's the same as the design location, a percentage of + extension with respect to the normal width. + """ + assert isinstance(instance, classes.GSInstance) + if self.tag == 'wdth': + # The user location is by default the same as the design location. + # TODO: (jany) change that later if there is support for general + # axis mappings in Glyphs + return self.get_design_loc(instance) + + user_loc = self.default_user_loc + if self.user_loc_key is not None: + # Only weight and with have a custom user location. + # The `user_loc_key` gives a "location code" = Glyphs UI string + user_loc = getattr(instance, self.user_loc_key) + user_loc = user_loc_string_to_value(self.tag, user_loc) + if user_loc is None: + user_loc = self.default_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_ = 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, instance, value): + """Set the user location of a Glyphs instance.""" + assert isinstance(instance, classes.GSInstance) + # 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_instance_string(self.tag, value) + value_for_code = user_loc_string_to_value(self.tag, code) + if self.user_loc_key is not None: + setattr(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) + instance.customParameters[self.user_loc_param] = class_ + except: + pass + + def set_user_loc_code(self, instance, code): + assert isinstance(instance, classes.GSInstance) + # 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(instance, self.user_loc_key, code) + + def set_ufo_user_loc(self, ufo, value): + if self.name not in ('Weight', 'Width'): + raise NotImplementedError + class_ = user_loc_value_to_class(self.tag, value) + ufo_key = "".join(['openTypeOS2', self.name, 'Class']) + setattr(ufo.info, ufo_key, class_) + + +WEIGHT_AXIS_DEF = AxisDefinition('wght', 'Weight', 'weightValue', 100.0, + 'weight', 'weightClass', 400.0) +WIDTH_AXIS_DEF = AxisDefinition('wdth', 'Width', 'widthValue', 100.0, + 'width', 'widthClass', 100.0) +CUSTOM_AXIS_DEF = AxisDefinition('XXXX', 'Custom', 'customValue', 0.0, + None, None, 0.0) +DEFAULT_AXES_DEFS = (WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF, CUSTOM_AXIS_DEF) + + +# 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 + # Second try: maybe the base style has regular in it as well + for master in font.masters: + name_without_regular = ' '.join( + n for n in master.name.split(' ') if n != 'Regular') + if name_without_regular == 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/background_image.py b/Lib/glyphsLib/builder/background_image.py new file mode 100644 index 000000000..c5a17f16d --- /dev/null +++ b/Lib/glyphsLib/builder/background_image.py @@ -0,0 +1,55 @@ +# 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 .constants import GLYPHS_PREFIX +from glyphsLib.types import Transform, Rect, Point, Size + +BACKGROUND_IMAGE_PREFIX = GLYPHS_PREFIX + 'backgroundImage.' +CROP_KEY = BACKGROUND_IMAGE_PREFIX + 'crop' +LOCKED_KEY = BACKGROUND_IMAGE_PREFIX + 'locked' +ALPHA_KEY = BACKGROUND_IMAGE_PREFIX + 'alpha' + + +def to_ufo_background_image(self, ufo_glyph, layer): + """Copy the backgound image from the GSLayer to the UFO Glyph.""" + image = layer.backgroundImage + if image is None: + return + ufo_image = ufo_glyph.image + ufo_image.fileName = image.path + ufo_image.transformation = image.transform + ufo_glyph.lib[CROP_KEY] = list(image.crop) + ufo_glyph.lib[LOCKED_KEY] = image.locked + ufo_glyph.lib[ALPHA_KEY] = image.alpha + + +def to_glyphs_background_image(self, ufo_glyph, layer): + """Copy the background image from the UFO Glyph to the GSLayer.""" + ufo_image = ufo_glyph.image + if ufo_image.fileName is None: + return + image = self.glyphs_module.GSBackgroundImage() + image.path = ufo_image.fileName + image.transform = Transform(*ufo_image.transformation) + if CROP_KEY in ufo_glyph.lib: + x, y, w, h = ufo_glyph.lib[CROP_KEY] + image.crop = Rect(Point(x, y), Size(w, h)) + if LOCKED_KEY in ufo_glyph.lib: + image.locked = ufo_glyph.lib[LOCKED_KEY] + if ALPHA_KEY in ufo_glyph.lib: + image.alpha = ufo_glyph.lib[ALPHA_KEY] + layer.backgroundImage = image diff --git a/Lib/glyphsLib/builder/blue_values.py b/Lib/glyphsLib/builder/blue_values.py index e30ae134d..9fb9862d9 100644 --- a/Lib/glyphsLib/builder/blue_values.py +++ b/Lib/glyphsLib/builder/blue_values.py @@ -33,4 +33,26 @@ def to_ufo_blue_values(self, ufo, master): def to_glyphs_blue_values(self, ufo, master): - pass + """Sets the GSFontMaster alignmentZones from the postscript blue values.""" + + zones = [] + blue_values = _pairs(ufo.info.postscriptBlueValues) + other_blues = _pairs(ufo.info.postscriptOtherBlues) + for y1, y2 in blue_values: + size = (y2 - y1) + if y2 == 0: + pos = 0 + size = -size + else: + pos = y1 + zones.append(self.glyphs_module.GSAlignmentZone(pos, size)) + for y1, y2 in other_blues: + size = (y1 - y2) + pos = y2 + zones.append(self.glyphs_module.GSAlignmentZone(pos, size)) + + master.alignmentZones = sorted(zones, key=lambda zone: -zone.position) + + +def _pairs(list): + return [list[i:i+2] for i in range(0, len(list), 2)] diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index aa5739443..716c1aa25 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -15,13 +15,22 @@ 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 +from textwrap import dedent import defcon -from glyphsLib import classes -from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX +from fontTools import designspaceLib + +from glyphsLib import classes, glyphdata_generated +from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX +from .axes import (WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF, find_base_style, + class_to_value) + +GLYPH_ORDER_KEY = PUBLIC_PREFIX + 'glyphOrder' class _LoggerMixin(object): @@ -42,8 +51,12 @@ class UFOBuilder(_LoggerMixin): def __init__(self, font, ufo_module=defcon, + designspace_module=designspaceLib, family_name=None, - propagate_anchors=True): + instance_dir=None, + propagate_anchors=True, + use_designspace=False, + minimize_glyphs_diffs=False): """Create a builder that goes from Glyphs to UFO + designspace. Keyword arguments: @@ -51,19 +64,38 @@ def __init__(self, ufo_module -- A Python module to use to build UFO objects (you can pass a custom module that has the same classes as the official defcon to get instances of your own classes) + designspace_module -- A Python module to use to build a Designspace + Document. Default is fontTools.designspaceLib. 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. + minimize_glyphs_diffs -- set to True to store extra info in UFOs + in order to get smaller diffs between .glyphs + .glyphs files when going glyphs->ufo->glyphs. """ 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 MutatorMath Designspace 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() + 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 @@ -72,33 +104,24 @@ def __init__(self, 'This Glyphs source was generated with an outdated version ' 'of Glyphs. The resulting UFOs may be incorrect.') - source_family_name = self.font.familyName if family_name is None: # use the source family name, and include all the instances - self.family_name = source_family_name + self.family_name = self.font.familyName self._do_filter_instances_by_family = False else: self.family_name = family_name # use a custom 'family_name' to name master UFOs, and only build # instances with matching 'familyName' custom parameter self._do_filter_instances_by_family = True - if family_name == source_family_name: - # if the 'family_name' provided is the same as the source, only - # include instances which do _not_ specify a custom 'familyName' - self._instance_family_name = None - else: - self._instance_family_name = family_name - - self.propagate_anchors = propagate_anchors - @property def masters(self): """Get an iterator over master UFOs that match the given family_name. """ - if self._ufos: - return self._ufos.values() - kerning_groups = {} + if self._sources: + for source in self._sources.values(): + yield source.font + return # Store set of actually existing master (layer) ids. This helps with # catching dangling layer data that Glyphs may ignore, e.g. when @@ -115,154 +138,154 @@ def masters(self): # on demand. self.to_ufo_font_attributes(self.family_name) - # get the 'glyphOrder' custom parameter as stored in the lib.plist. - # We assume it's the same for all ufos. - first_ufo = next(iter(self._ufos.values())) - glyphOrder_key = PUBLIC_PREFIX + 'glyphOrder' - if glyphOrder_key in first_ufo.lib: - glyph_order = first_ufo.lib[glyphOrder_key] - else: - glyph_order = [] - sorted_glyphset = set(glyph_order) - for glyph in self.font.glyphs: - self.to_ufo_glyph_groups(kerning_groups, glyph) - glyph_name = glyph.name - if glyph_name not in sorted_glyphset: - # glyphs not listed in the 'glyphOrder' custom parameter but still - # in the font are appended after the listed glyphs, in the order - # in which they appear in the source file - glyph_order.append(glyph_name) - for layer in glyph.layers.values(): - layer_id = layer.layerId - layer_name = layer.name - - assoc_id = layer.associatedMasterId - if assoc_id != layer.layerId: + if layer.associatedMasterId != layer.layerId: + # The layer is not the main layer of a master # Store all layers, even the invalid ones, and just skip # them and print a warning below. - supplementary_layer_data.append( - (assoc_id, glyph_name, layer_name, layer)) + supplementary_layer_data.append((glyph, layer)) continue - ufo = self._ufos[layer_id] - ufo_glyph = ufo.newGlyph(glyph_name) + ufo_layer = self.to_ufo_layer(glyph, layer) + ufo_glyph = ufo_layer.newGlyph(glyph.name) self.to_ufo_glyph(ufo_glyph, layer, glyph) - for layer_id, glyph_name, layer_name, layer_data \ - in supplementary_layer_data: - if (layer_data.layerId not in master_layer_ids - and layer_data.associatedMasterId not in master_layer_ids): + for glyph, layer in supplementary_layer_data: + if (layer.layerId not in master_layer_ids and + layer.associatedMasterId not in master_layer_ids): self.logger.warn( '{}, glyph "{}": Layer "{}" is dangling and will be ' 'skipped. Did you copy a glyph from a different font? If ' 'so, you should clean up any phantom layers not associated ' - 'with an actual master.'.format( - self.font.familyName, glyph_name, layer_data.layerId)) + 'with an actual master.'.format(self.font.familyName, + glyph.name, layer.layerId)) continue - if not layer_name: + if not layer.name: # Empty layer names are invalid according to the UFO spec. self.logger.warn( '{}, glyph "{}": Contains layer without a name which will ' - 'be skipped.'.format(self.font.familyName, glyph_name)) + 'be skipped.'.format(self.font.familyName, glyph.name)) continue - ufo_font = self._ufos[layer_id] - if layer_name not in ufo_font.layers: - ufo_layer = ufo_font.newLayer(layer_name) - else: - ufo_layer = ufo_font.layers[layer_name] - ufo_glyph = ufo_layer.newGlyph(glyph_name) - self.to_ufo_glyph(ufo_glyph, layer_data, layer_data.parent) + ufo_layer = self.to_ufo_layer(glyph, layer) + ufo_glyph = ufo_layer.newGlyph(glyph.name) + self.to_ufo_glyph(ufo_glyph, layer, layer.parent) - for ufo in self._ufos.values(): - ufo.lib[glyphOrder_key] = glyph_order + 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) - self.to_ufo_kerning_groups(ufo, kerning_groups) - - for master_id, kerning in self.font.kerning.items(): - self.to_ufo_kerning(self._ufos[master_id], kerning) - - return self._ufos.values() - + for layer in ufo.layers: + self.to_ufo_layer_lib(layer) - @property - def instances(self): - """Get an iterator over interpolated UFOs of instances.""" - # TODO? - return [] + self.to_ufo_features() # This depends on the glyphOrder key + self.to_ufo_groups() + self.to_ufo_kerning() + for source in self._sources.values(): + yield source.font @property def designspace(self): - """Get a designspace Document instance that links the masters together. + """Get a designspace Document instance that links the masters together + and holds instance data. """ - # TODO? - pass - - + if self._designspace_is_complete: + return self._designspace + self._designspace_is_complete = True + ufos = list(self.masters) # Make sure that the UFOs are built + 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 @property def instance_data(self): instances = self.font.instances if self._do_filter_instances_by_family: instances = list( - filter_instances_by_family(instances, - self._instance_family_name)) - instance_data = {'data': instances} + filter_instances_by_family(instances, self.family_name)) + instance_data = {'data': instances, 'designspace': self.designspace} first_ufo = next(iter(self.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 varfont_origin_key = "Variation Font Origin" - varfont_origin = first_ufo.lib.get(GLYPHS_PREFIX + varfont_origin_key) + varfont_origin = first_ufo.lib.get(FONT_CUSTOM_PARAM_PREFIX + + varfont_origin_key) if varfont_origin: instance_data[varfont_origin_key] = varfont_origin return instance_data - - # Implementation is spit into one file per feature + # 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 - from .components import to_ufo_draw_components + from .components import to_ufo_components, to_ufo_smart_component_axes from .custom_params import to_ufo_custom_params from .features import to_ufo_features from .font import to_ufo_font_attributes - from .glyph import (to_ufo_glyph, to_ufo_glyph_background, - to_ufo_glyph_libdata) + from .glyph import to_ufo_glyph, to_ufo_glyph_background + from .groups import to_ufo_groups from .guidelines import to_ufo_guidelines - from .kerning import (to_ufo_kerning, to_ufo_glyph_groups, - to_ufo_kerning_groups) + from .hints import to_ufo_hints + from .instances import to_designspace_instances + from .kerning import to_ufo_kerning + from .layers import to_ufo_layer, to_ufo_background_layer + from .masters import to_ufo_master_attributes from .names import to_ufo_names - from .paths import to_ufo_draw_paths - from .user_data import to_ufo_family_user_data, to_ufo_master_user_data + 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, + to_ufo_layer_user_data, to_ufo_node_user_data) def filter_instances_by_family(instances, family_name=None): """Yield instances whose 'familyName' custom parameter is equal to 'family_name'. """ - for instance in instances: - familyName = None - for p in instance.customParameters: - param, value = p.name, p.value - if param == 'familyName': - familyName = value - if familyName == family_name: - yield instance + return (i for i in instances if i.familyName == family_name) class GlyphsBuilder(_LoggerMixin): """Builder for UFO + designspace to Glyphs.""" - def __init__(self, ufos, designspace=None, glyphs_module=classes): + def __init__(self, + ufos=[], + designspace=None, + glyphs_module=classes, + 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: (jany) 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 @@ -272,31 +295,187 @@ def __init__(self, ufos, designspace=None, glyphs_module=classes): instances of your own classes, or pass the Glyphs.app module that holds the official classes to import UFOs into Glyphs.app) + minimize_ufo_diffs -- set to True to store extra info in .glyphs files + in order to get smaller diffs between UFOs + when going UFOs->glyphs->UFOs """ - self.ufos = ufos - self.designspace = designspace self.glyphs_module = glyphs_module + self.minimize_ufo_diffs = minimize_ufo_diffs + + if designspace is not None: + if ufos: + raise NotImplementedError + self.designspace = self._valid_designspace(designspace) + elif ufos: + self.designspace = self._fake_designspace(ufos) + else: + raise RuntimeError( + 'Please provide a designspace or at least one UFO.') self._font = None """The GSFont that will be built.""" - @property def font(self): """Get the GSFont built from the UFOs + designspace.""" if self._font is not None: return self._font + # Sort UFOS in the original order from the Glyphs file + sorted_sources = self.to_glyphs_ordered_masters() + self._font = self.glyphs_module.GSFont() - 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(source, master) self._font.masters.insert(len(self._font.masters), master) - # TODO: all the other stuff! - return self._font + self._sources[master.id] = source + + for layer in _sorted_backgrounds_last(source.font.layers): + self.to_glyphs_layer_lib(layer) + for glyph in layer: + self.to_glyphs_glyph(glyph, layer, master) + + self.to_glyphs_features() + self.to_glyphs_groups() + self.to_glyphs_kerning() + + # Now that all GSGlyph are built, restore the glyph order + 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) We only do that on the first one. Maybe we should + # merge the various `public.glyphorder` values? + + # 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 - # Implementation is spit into one file per feature - from .font import to_glyphs_font_attributes + def _valid_designspace(self, designspace): + """Make sure that the user-provided designspace has loaded fonts and + that names are the same as those from the UFOs. + """ + # TODO: (jany) really make a copy to avoid modifying the original object + copy = designspace + for source in copy.sources: + if not hasattr(source, 'font') or source.font is None: + if source.path: + # FIXME: (jany) consider not changing the caller's objects + source.font = defcon.Font(source.path) + else: + dirname = os.path.dirname(designspace.path) + ufo_path = os.path.join(dirname, source.filename) + source.font = defcon.Font(ufo_path) + if source.location is None: + source.location = {} + for name in ('familyName', 'styleName'): + if getattr(source, name) != getattr(source.font.info, name): + self.logger.warn(dedent('''\ + The {name} is different between the UFO and the designspace source: + source filename: {filename} + source {name}: {source_name} + ufo {name}: {ufo_name} + + The UFO name will be used. + ''').format(name=name, + filename=source.filename, + source_name=getattr(source, name), + ufo_name=getattr(source.font.info, name))) + setattr(source, name, getattr(source.font.info, name)) + return copy + + def _fake_designspace(self, ufos): + """Build a fake designspace with the given UFOs as sources, so that all + builder functions can rely on the presence of a designspace. + """ + designspace = designspaceLib.DesignSpaceDocument() + + ufo_to_location = defaultdict(dict) + + # Make weight and width axis if relevant + for info_key, axis_def in zip( + ('openTypeOS2WeightClass', 'openTypeOS2WidthClass'), + (WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF)): + 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 + source.familyName = ufo.info.familyName + source.styleName = ufo.info.styleName + # source.name = '%s %s' % (source.familyName, source.styleName) + source.path = ufo.path + 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, + to_glyphs_smart_component_axes) + from .custom_params import to_glyphs_custom_params + from .features import to_glyphs_features + from .font import to_glyphs_font_attributes, to_glyphs_ordered_masters + from .glyph import to_glyphs_glyph + from .groups import to_glyphs_groups + from .guidelines import to_glyphs_guidelines + from .hints import to_glyphs_hints + from .instances import to_glyphs_instances + from .kerning import to_glyphs_kerning + from .layers import to_glyphs_layer, to_glyphs_layer_order + 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, + to_glyphs_glyph_user_data, + to_glyphs_layer_lib, + to_glyphs_layer_user_data, + to_glyphs_node_user_data) + + +def _sorted_backgrounds_last(ufo_layers): + # Stable sort that groups all foregrounds first and all backgrounds last + return sorted(ufo_layers, key=lambda layer: + 1 if layer.name.endswith('.background') else 0) diff --git a/Lib/glyphsLib/builder/common.py b/Lib/glyphsLib/builder/common.py index 8646b92c8..9bb4aff10 100644 --- a/Lib/glyphsLib/builder/common.py +++ b/Lib/glyphsLib/builder/common.py @@ -15,9 +15,26 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +import datetime +from glyphsLib.types import parse_datetime + UFO_FORMAT = '%Y/%m/%d %H:%M:%S' def to_ufo_time(datetime_obj): """Format a datetime object as specified for UFOs.""" return datetime_obj.strftime(UFO_FORMAT) + + +def from_ufo_time(string): + """Parses a datetime as specified for UFOs into a datetime object.""" + return datetime.datetime.strptime(string, UFO_FORMAT) + + +def from_loose_ufo_time(string): + """Parses a datetime as specified for UFOs into a datetime object, + or as the Glyphs formet.""" + try: + return from_ufo_time(string) + except ValueError: + return parse_datetime(string) diff --git a/Lib/glyphsLib/builder/components.py b/Lib/glyphsLib/builder/components.py index b4bc26228..d0d45c5ef 100644 --- a/Lib/glyphsLib/builder/components.py +++ b/Lib/glyphsLib/builder/components.py @@ -15,10 +15,76 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +from glyphsLib.types import Transform -def to_ufo_draw_components(self, pen, components): +from .constants import GLYPHS_PREFIX + + +def to_ufo_components(self, ufo_glyph, layer): """Draw .glyphs components onto a pen, adding them to the parent glyph.""" + pen = ufo_glyph.getPointPen() - for component in components: + for component in layer.components: pen.addComponent(component.name, component.transform) + + # data related to components stored in lists of booleans + # each list's elements correspond to the components in order + for key in ['alignment', 'locked', 'smartComponentValues']: + values = [getattr(c, key) for c in layer.components] + if any(values): + ufo_glyph.lib[_lib_key(key)] = values + + +def to_glyphs_components(self, ufo_glyph, layer): + for comp in ufo_glyph.components: + component = self.glyphs_module.GSComponent(comp.baseGlyph) + component.transform = Transform(*comp.transformation) + layer.components.append(component) + + for key in ['alignment', 'locked', 'smartComponentValues']: + if _lib_key(key) not in ufo_glyph.lib: + continue + # FIXME: (jany) move to using component identifiers for robustness + values = ufo_glyph.lib[_lib_key(key)] + for component, value in zip(layer.components, values): + if value is not None: + setattr(component, key, value) + + +def _lib_key(key): + key = key[0].upper() + key[1:] + return '%scomponents%s' % (GLYPHS_PREFIX, key) + + +AXES_LIB_KEY = GLYPHS_PREFIX + 'smartComponentAxes' + + +def to_ufo_smart_component_axes(self, ufo_glyph, glyph): + def _to_ufo_axis(axis): + return { + 'name': axis.name, + 'bottomName': axis.bottomName, + 'bottomValue': axis.bottomValue, + 'topName': axis.topName, + 'topValue': axis.topValue, + } + + if glyph.smartComponentAxes: + ufo_glyph.lib[AXES_LIB_KEY] = [ + _to_ufo_axis(a) for a in glyph.smartComponentAxes] + + +def to_glyphs_smart_component_axes(self, ufo_glyph, glyph): + def _to_glyphs_axis(axis): + res = self.glyphs_module.GSSmartComponentAxis() + res.name = axis['name'] + res.bottomName = axis['bottomName'] + res.bottomValue = axis['bottomValue'] + res.topValue = axis['topValue'] + res.topName = axis['topName'] + return res + + if AXES_LIB_KEY in ufo_glyph.lib: + glyph.smartComponentAxes = [ + _to_glyphs_axis(a) for a in ufo_glyph.lib[AXES_LIB_KEY]] diff --git a/Lib/glyphsLib/builder/constants.py b/Lib/glyphsLib/builder/constants.py index 7ca7c0c07..3164b2d9b 100644 --- a/Lib/glyphsLib/builder/constants.py +++ b/Lib/glyphsLib/builder/constants.py @@ -22,6 +22,9 @@ UFO2FT_FILTERS_KEY = 'com.github.googlei18n.ufo2ft.filters' UFO2FT_USE_PROD_NAMES_KEY = 'com.github.googlei18n.ufo2ft.useProductionNames' +MASTER_CUSTOM_PARAM_PREFIX = GLYPHS_PREFIX + 'customParameter.GSFontMaster.' +FONT_CUSTOM_PARAM_PREFIX = GLYPHS_PREFIX + 'customParameter.GSFont.' + GLYPHS_COLORS = ( '0.85,0.26,0.06,1', '0.99,0.62,0.11,1', @@ -76,3 +79,5 @@ 850: 62, 437: 63, } + +REVERSE_CODEPAGE_RANGES = {value: key for key, value in CODEPAGE_RANGES.items()} diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 5a622bde3..17f5b5c6a 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -15,163 +15,588 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +from collections import defaultdict import re -from glyphsLib.util import bin_to_int_list -from .filters import parse_glyphs_filter -from .constants import (GLYPHS_PREFIX, PUBLIC_PREFIX, CODEPAGE_RANGES, - UFO2FT_FILTERS_KEY, UFO2FT_USE_PROD_NAMES_KEY) +from glyphsLib.util import bin_to_int_list, int_list_to_bin +from .filters import parse_glyphs_filter, write_glyphs_filter +from .constants import (GLYPHS_PREFIX, PUBLIC_PREFIX, + UFO2FT_FILTERS_KEY, UFO2FT_USE_PROD_NAMES_KEY, + CODEPAGE_RANGES, REVERSE_CODEPAGE_RANGES) from .features import replace_feature +"""Set Glyphs custom parameters in UFO info or lib, where appropriate. + +Custom parameter data will be extracted from a Glyphs object such as GSFont, +GSFontMaster or GSInstance by wrapping it in the GlyphsObjectProxy. +This proxy normalizes and speeds up the API used to access custom parameters, +and also keeps track of which customParameters have been read from the object. + +Note: + In the special case of GSInstance -> UFO, the source object is not + actually the GSInstance but a designspace InstanceDescriptor wrapped in + InstanceDescriptorAsGSInstance. This is because the generation of + instance UFOs from a Glyphs font happens in two steps: + + 1. the GSFont is turned into a designspace + master UFOS + 2. the designspace + master UFOs are interpolated into instance UFOs + + We want step 2. to rely only on information from the designspace, that's why + we use the InstanceDescriptor as a source of customParameters to put into + the instance UFO. + +In the other direction, put information from UFO info or lib into + +Custom parameter data can be pre-parsed out of Glyphs data and provided via +the `parsed` argument, otherwise `data` should be provided and will be +parsed. The `parsed` option is provided so that custom params can be popped +from Glyphs data once and used several times; in general this is used for +debugging purposes (to detect unused Glyphs data). + +The `non_info` argument can be used to specify potential UFO info attributes +which should not be put in UFO info. +""" + +CUSTOM_PARAM_PREFIX = GLYPHS_PREFIX + 'customParameter.' + + +def identity(value): + return value + + +class GlyphsObjectProxy(object): + """Accelerate and record access to the glyphs object's custom parameters""" + def __init__(self, glyphs_object, glyphs_module): + self._owner = glyphs_object + # This is a key part to be used in UFO lib keys to be able to choose + # between master and font attributes during roundtrip + self.sub_key = glyphs_object.__class__.__name__ + '.' + self._glyphs_module = glyphs_module + self._lookup = defaultdict(list) + for param in glyphs_object.customParameters: + self._lookup[param.name].append(param.value) + self._handled = set() + + def get_attribute_value(self, key): + if not hasattr(self._owner, key): + return None + return getattr(self._owner, key) + + def set_attribute_value(self, key, value): + if not hasattr(self._owner, key): + return + setattr(self._owner, key, value) + + def get_custom_value(self, key): + """Return the first and only custom parameter matching the given name.""" + self._handled.add(key) + values = self._lookup[key] + if len(values) > 1: + raise RuntimeError('More than one value for this customParameter: {}'.format(key)) + if values: + return values[0] + return None + + def get_custom_values(self, key): + """Return a set of values for the given customParameter name.""" + self._handled.add(key) + return self._lookup[key] + + def set_custom_value(self, key, value): + """Set one custom parameter with the given value. + We assume that the list of custom parameters does not already contain + the given parameter so we only append. + """ + self._owner.customParameters.append( + self._glyphs_module.GSCustomParameter(name=key, value=value)) + + def set_custom_values(self, key, values): + """Set several values for the customParameter with the given key. + We append one GSCustomParameter per value. + """ + for value in values: + self.set_custom_value(key, value) + + def unhandled_custom_parameters(self): + for param in self._owner.customParameters: + if param.name not in self._handled: + yield param + + +class UFOProxy(object): + """Record access to the UFO's lib custom parameters""" + def __init__(self, ufo): + self._owner = ufo + self._handled = set() + + def has_info_attr(self, name): + return hasattr(self._owner.info, name) + + def get_info_value(self, name): + return getattr(self._owner.info, name) + + def set_info_value(self, name, value): + setattr(self._owner.info, name, value) + + def has_lib_key(self, name): + return name in self._owner.lib + + def get_lib_value(self, name): + if name not in self._owner.lib: + return None + self._handled.add(name) + return self._owner.lib[name] + + def set_lib_value(self, name, value): + self._owner.lib[name] = value + + def unhandled_lib_items(self): + for key, value in self._owner.lib.items(): + if (key.startswith(CUSTOM_PARAM_PREFIX) and + key not in self._handled): + yield (key, value) + + +class AbstractParamHandler(object): + # @abstractmethod + def to_glyphs(self): + pass + + # @abstractmethod + def to_ufo(self): + pass + + +class ParamHandler(AbstractParamHandler): + def __init__(self, glyphs_name, ufo_name=None, + glyphs_long_name=None, glyphs_multivalued=False, + ufo_prefix=CUSTOM_PARAM_PREFIX, ufo_info=True, + ufo_default=None, + value_to_ufo=identity, value_to_glyphs=identity): + self.glyphs_name = glyphs_name + self.glyphs_long_name = glyphs_long_name + self.glyphs_multivalued = glyphs_multivalued + # By default, they have the same name in both + self.ufo_name = ufo_name or glyphs_name + self.ufo_prefix = ufo_prefix + self.ufo_info = ufo_info + self.ufo_default = ufo_default + # Value transformation functions + self.value_to_ufo = value_to_ufo + self.value_to_glyphs = value_to_glyphs + + # By default, the parameter is read from/written to: + # - the Glyphs object's customParameters + # - the UFO's info object if it has a matching attribute, else the lib + def to_glyphs(self, glyphs, ufo): + ufo_value = self._read_from_ufo(glyphs, ufo) + if ufo_value is None: + return + glyphs_value = self.value_to_glyphs(ufo_value) + self._write_to_glyphs(glyphs, glyphs_value) + + def to_ufo(self, glyphs, ufo): + glyphs_value = self._read_from_glyphs(glyphs) + if glyphs_value is None: + return + ufo_value = self.value_to_ufo(glyphs_value) + self._write_to_ufo(glyphs, ufo, ufo_value) + + def _read_from_glyphs(self, glyphs): + # Try both the prefixed (long) name and the short name + if self.glyphs_multivalued: + getter = glyphs.get_custom_values + else: + getter = glyphs.get_custom_value + # The value registered using the small name has precedence + small_name_value = getter(self.glyphs_name) + if small_name_value is not None: + return small_name_value + if self.glyphs_long_name is not None: + return getter(self.glyphs_long_name) + return None + + def _write_to_glyphs(self, glyphs, value): + # Never write the prefixed (long) name? + # FIXME: (jany) maybe should rather preserve the naming choice of user + if self.glyphs_multivalued: + glyphs.set_custom_values(self.glyphs_name, value) + else: + glyphs.set_custom_value(self.glyphs_name, value) -def to_ufo_custom_params(self, ufo, master): - misc = ['DisplayStrings', 'disablesAutomaticAlignment', 'disablesNiceNames'] - custom_params = parse_custom_params(self.font, misc) - set_custom_params(ufo, parsed=custom_params) - # the misc attributes double as deprecated info attributes! - # they are Glyphs-related, not OpenType-related, and don't go in info - misc = ('customValue', 'weightValue', 'widthValue') - set_custom_params(ufo, data=master, misc_keys=misc, non_info=misc) - - set_default_params(ufo) + def _read_from_ufo(self, glyphs, ufo): + if self.ufo_info and ufo.has_info_attr(self.ufo_name): + return ufo.get_info_value(self.ufo_name) + else: + ufo_prefix = self.ufo_prefix + if ufo_prefix == CUSTOM_PARAM_PREFIX: + ufo_prefix += glyphs.sub_key + return ufo.get_lib_value(ufo_prefix + self.ufo_name) + + def _write_to_ufo(self, glyphs, ufo, value): + if self.ufo_default is not None and value == self.ufo_default: + return + if self.ufo_info and ufo.has_info_attr(self.ufo_name): + # most OpenType table entries go in the info object + ufo.set_info_value(self.ufo_name, value) + else: + # everything else gets dumped in the lib + ufo_prefix = self.ufo_prefix + if ufo_prefix == CUSTOM_PARAM_PREFIX: + ufo_prefix += glyphs.sub_key + ufo.set_lib_value(ufo_prefix + self.ufo_name, value) + + +KNOWN_PARAM_HANDLERS = [] + + +def register(handler): + KNOWN_PARAM_HANDLERS.append(handler) + + +GLYPHS_UFO_CUSTOM_PARAMS = ( + ('hheaAscender', 'openTypeHheaAscender'), + ('hheaDescender', 'openTypeHheaDescender'), + ('hheaLineGap', 'openTypeHheaLineGap'), + ('compatibleFullName', 'openTypeNameCompatibleFullName'), + ('description', 'openTypeNameDescription'), + ('license', 'openTypeNameLicense'), + ('licenseURL', 'openTypeNameLicenseURL'), + ('preferredFamilyName', 'openTypeNamePreferredFamilyName'), + ('preferredSubfamilyName', 'openTypeNamePreferredSubfamilyName'), + ('sampleText', 'openTypeNameSampleText'), + ('WWSFamilyName', 'openTypeNameWWSFamilyName'), + ('WWSSubfamilyName', 'openTypeNameWWSSubfamilyName'), + ('panose', 'openTypeOS2Panose'), + ('fsType', 'openTypeOS2Type'), + ('typoAscender', 'openTypeOS2TypoAscender'), + ('typoDescender', 'openTypeOS2TypoDescender'), + ('typoLineGap', 'openTypeOS2TypoLineGap'), + ('unicodeRanges', 'openTypeOS2UnicodeRanges'), + ('vendorID', 'openTypeOS2VendorID'), + # ('weightClass', 'openTypeOS2WeightClass'), + # ('widthClass', 'openTypeOS2WidthClass'), + # ('winAscent', 'openTypeOS2WinAscent'), + # ('winDescent', 'openTypeOS2WinDescent'), + ('vheaVertTypoAscender', 'openTypeVheaVertTypoAscender'), + ('vheaVertTypoDescender', 'openTypeVheaVertTypoDescender'), + ('vheaVertTypoLineGap', 'openTypeVheaVertTypoLineGap'), + # Postscript parameters + ('blueScale', 'postscriptBlueScale'), + ('blueShift', 'postscriptBlueShift'), + ('isFixedPitch', 'postscriptIsFixedPitch'), + ('underlinePosition', 'postscriptUnderlinePosition'), + ('underlineThickness', 'postscriptUnderlineThickness'), +) +for glyphs_name, ufo_name in GLYPHS_UFO_CUSTOM_PARAMS: + register(ParamHandler(glyphs_name, ufo_name, glyphs_long_name=ufo_name)) + +# TODO: (jany) for all the following fields, check that they are stored in a +# meaningful Glyphs customParameter. Maybe they have short names? +GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME = ( + 'openTypeHheaCaretSlopeRun', + 'openTypeVheaCaretSlopeRun', + 'openTypeHheaCaretSlopeRise', + 'openTypeVheaCaretSlopeRise', + 'openTypeHheaCaretOffset', + 'openTypeVheaCaretOffset', + 'openTypeHeadLowestRecPPEM', + 'openTypeHeadFlags', + 'openTypeNameVersion', + 'openTypeNameUniqueID', + + # TODO: (jany) look at https://forum.glyphsapp.com/t/name-table-entry-win-id4/3811/10 + # Use Name Table Entry for the next param + 'openTypeNameRecords', + + 'openTypeOS2FamilyClass', + 'openTypeOS2SubscriptXSize', + 'openTypeOS2SubscriptYSize', + 'openTypeOS2SubscriptXOffset', + 'openTypeOS2SubscriptYOffset', + 'openTypeOS2SuperscriptXSize', + 'openTypeOS2SuperscriptYSize', + 'openTypeOS2SuperscriptXOffset', + 'openTypeOS2SuperscriptYOffset', + 'openTypeOS2StrikeoutSize', + 'openTypeOS2StrikeoutPosition', + 'postscriptFontName', + 'postscriptFullName', + 'postscriptSlantAngle', + 'postscriptUniqueID', + + # Should this be handled in `blue_values.py`? + # 'postscriptFamilyBlues', + # 'postscriptFamilyOtherBlues', + 'postscriptBlueFuzz', + + 'postscriptForceBold', + 'postscriptDefaultWidthX', + 'postscriptNominalWidthX', + 'postscriptWeightName', + 'postscriptDefaultCharacter', + 'postscriptWindowsCharacterSet', + + 'macintoshFONDFamilyID', + 'macintoshFONDName', + + 'trademark', + + 'styleMapFamilyName', + 'styleMapStyleName', +) +for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: + register(ParamHandler(name)) + + +# TODO: (jany) handle dynamic version number replacement +register(ParamHandler('versionString', 'openTypeNameVersion')) + + +class EmptyListDefaultParamHandler(ParamHandler): + def to_glyphs(self, glyphs, ufo): + ufo_value = self._read_from_ufo(glyphs, ufo) + # Ingore default value == empty list + if ufo_value is None or ufo_value == []: + return + glyphs_value = self.value_to_glyphs(ufo_value) + self._write_to_glyphs(glyphs, glyphs_value) + +register(EmptyListDefaultParamHandler('postscriptFamilyBlues')) +register(EmptyListDefaultParamHandler('postscriptFamilyOtherBlues')) + + +# convert code page numbers to OS/2 ulCodePageRange bits +register(ParamHandler( + glyphs_name='codePageRanges', + ufo_name='openTypeOS2CodePageRanges', + value_to_ufo=lambda value: [CODEPAGE_RANGES[v] for v in value], + # TODO: (jany) handle KeyError, store into userData + value_to_glyphs=lambda value: [REVERSE_CODEPAGE_RANGES[v] for v in value if v in REVERSE_CODEPAGE_RANGES] +)) +# But don't do the conversion if the Glyphs param name is written in full +register(ParamHandler( + glyphs_name='openTypeOS2CodePageRanges', + ufo_name='openTypeOS2CodePageRanges', + # Don't do any conversion when writing to UFO + # value_to_ufo=identity, + # Don't use this handler to write back to Glyphs + value_to_glyphs=lambda value: value # TODO: (jany) only write if contains non-codepage values + # TODO: (jany) add test with non-codepage values +)) + +# enforce that winAscent/Descent are positive, according to UFO spec +for glyphs_name in ('winAscent', 'winDescent'): + ufo_name = 'openTypeOS2W' + glyphs_name[1:] + register(ParamHandler( + glyphs_name, ufo_name, glyphs_long_name=ufo_name, + value_to_ufo=abs, + value_to_glyphs=abs, + )) + +# The value of these could be a float, and ufoLib/defcon expect an int. +for glyphs_name in ('weightClass', 'widthClass'): + ufo_name = 'openTypeOS2W' + glyphs_name[1:] + register(ParamHandler(glyphs_name, ufo_name, value_to_ufo=int)) + + +# convert Glyphs' GASP Table to UFO openTypeGaspRangeRecords +def to_ufo_gasp_table(value): + # XXX maybe the parser should cast the gasp values to int? + value = {int(k): int(v) for k, v in value.items()} + gasp_records = [] + # gasp range records must be sorted in ascending rangeMaxPPEM + for max_ppem, gasp_behavior in sorted(value.items()): + gasp_records.append({ + 'rangeMaxPPEM': max_ppem, + 'rangeGaspBehavior': bin_to_int_list(gasp_behavior)}) + return gasp_records + + +def to_glyphs_gasp_table(value): + return { + str(record['rangeMaxPPEM']): + int_list_to_bin(record['rangeGaspBehavior']) + for record in value + } + +register(ParamHandler( + glyphs_name='GASP Table', + ufo_name='openTypeGaspRangeRecords', + value_to_ufo=to_ufo_gasp_table, + value_to_glyphs=to_glyphs_gasp_table, +)) + +register(ParamHandler( + glyphs_name='Disable Last Change', + ufo_name='disablesLastChange', +)) + +register(ParamHandler( + # convert between Glyphs.app's and ufo2ft's equivalent parameter + glyphs_name="Don't use Production Names", + ufo_name=UFO2FT_USE_PROD_NAMES_KEY, + ufo_prefix='', + value_to_ufo=lambda value: not value, + value_to_glyphs=lambda value: not value, +)) + + +class MiscParamHandler(ParamHandler): + """Copy GSFont attributes to ufo lib""" + def _read_from_glyphs(self, glyphs): + return glyphs.get_attribute_value(self.glyphs_name) + + def _write_to_glyphs(self, glyphs, value): + glyphs.set_attribute_value(self.glyphs_name, value) + + +register(MiscParamHandler(glyphs_name='DisplayStrings')) +register(MiscParamHandler(glyphs_name='disablesAutomaticAlignment')) +register(MiscParamHandler(glyphs_name='iconName')) + +# deal with any Glyphs naming quirks here +register(MiscParamHandler( + glyphs_name='disablesNiceNames', + ufo_name='useNiceNames', + value_to_ufo=lambda value: int(not value), + value_to_glyphs=lambda value: not bool(value) +)) + +for number in ('', '1', '2', '3'): + register(MiscParamHandler('customValue' + number, ufo_info=False)) +register(MiscParamHandler('weightValue', ufo_info=False)) +register(MiscParamHandler('widthValue', ufo_info=False)) + + +def append_unique(array, value): + if value not in array: + array.append(value) + + +class OS2SelectionParamHandler(AbstractParamHandler): + flags = ( + ('Has WWS Names', 8), + ('Use Typo Metrics', 7), + ) + + def to_glyphs(self, glyphs, ufo): + ufo_flags = ufo.get_info_value('openTypeOS2Selection') + if ufo_flags is None: + return + for glyphs_name, value in self.flags: + if value in ufo_flags: + glyphs.set_custom_value(glyphs_name, True) + + def to_ufo(self, glyphs, ufo): + for glyphs_name, value in self.flags: + if glyphs.get_custom_value(glyphs_name): + selection = ufo.get_info_value('openTypeOS2Selection') + if selection is None: + selection = [] + if value not in selection: + selection.append(value) + ufo.set_info_value('openTypeOS2Selection', selection) + + +register(OS2SelectionParamHandler()) + +# Do NOT use public.glyphOrder +register(ParamHandler('glyphOrder', ufo_prefix=GLYPHS_PREFIX)) + + +# See https://github.com/googlei18n/glyphsLib/issues/214 +class FilterParamHandler(AbstractParamHandler): + def glyphs_names(self): + return ('Filter', 'PreFilter') + + def ufo_names(self): + return (UFO2FT_FILTERS_KEY,) + + def to_glyphs(self, glyphs, ufo): + ufo_filters = ufo.get_lib_value(UFO2FT_FILTERS_KEY) + if ufo_filters is None: + return + for ufo_filter in ufo_filters: + glyphs_filter, is_pre = write_glyphs_filter(ufo_filter) + glyphs.set_custom_values('PreFilter' if is_pre else 'Filter', + glyphs_filter) + + def to_ufo(self, glyphs, ufo): + ufo_filters = [] + for pre_filter in glyphs.get_custom_values('PreFilter'): + ufo_filters.append(parse_glyphs_filter(pre_filter, is_pre=True)) + for filter in glyphs.get_custom_values('Filter'): + ufo_filters.append(parse_glyphs_filter(filter, is_pre=False)) + + if not ufo_filters: + return + if not ufo.has_lib_key(UFO2FT_FILTERS_KEY): + ufo.set_lib_value(UFO2FT_FILTERS_KEY, []) + existing = ufo.get_lib_value(UFO2FT_FILTERS_KEY) + existing.extend(ufo_filters) + +register(FilterParamHandler()) + + +class ReplaceFeatureParamHandler(AbstractParamHandler): + def to_ufo(self, glyphs, ufo): + for value in glyphs.get_custom_values('Replace Feature'): + tag, repl = re.split("\s*;\s*", value, 1) + ufo._owner.features.text = replace_feature( + tag, repl, ufo._owner.features.text or "") + def to_glyphs(self, glyphs, ufo): + # TODO: (jany) The "Replace Feature" custom parameter can be used to + # have one master/instance with different features than what is stored + # in the GSFont. When going from several UFOs to one GSFont, we could + # detect when UFOs have different features, put the common ones in + # GSFont and replace the different ones with this custom parameter. + # See the file `tests/builder/features_test.py`. + pass -def set_custom_params(ufo, parsed=None, data=None, misc_keys=(), non_info=()): - """Set Glyphs custom parameters in UFO info or lib, where appropriate. +register(ReplaceFeatureParamHandler()) - Custom parameter data can be pre-parsed out of Glyphs data and provided via - the `parsed` argument, otherwise `data` should be provided and will be - parsed. The `parsed` option is provided so that custom params can be popped - from Glyphs data once and used several times; in general this is used for - debugging purposes (to detect unused Glyphs data). - The `non_info` argument can be used to specify potential UFO info attributes - which should not be put in UFO info. - """ +def to_ufo_custom_params(self, ufo, glyphs_object): + # glyphs_module=None because we shouldn't instanciate any Glyphs classes + glyphs_proxy = GlyphsObjectProxy(glyphs_object, glyphs_module=None) + ufo_proxy = UFOProxy(ufo) - if parsed is None: - parsed = parse_custom_params(data or {}, misc_keys) - else: - assert data is None, "Shouldn't provide parsed data and data to parse." + for handler in KNOWN_PARAM_HANDLERS: + handler.to_ufo(glyphs_proxy, ufo_proxy) - fsSelection_flags = {'Use Typo Metrics', 'Has WWS Names'} - for name, value in parsed: - name = normalize_custom_param_name(name) + for param in glyphs_proxy.unhandled_custom_parameters(): + name = _normalize_custom_param_name(param.name) + ufo.lib[CUSTOM_PARAM_PREFIX + glyphs_proxy.sub_key + name] = param.value - if name == "Don't use Production Names": - # convert between Glyphs.app's and ufo2ft's equivalent parameter - ufo.lib[UFO2FT_USE_PROD_NAMES_KEY] = not value - continue + _set_default_params(ufo) - if name in fsSelection_flags: - if value: - if ufo.info.openTypeOS2Selection is None: - ufo.info.openTypeOS2Selection = [] - if name == 'Use Typo Metrics': - ufo.info.openTypeOS2Selection.append(7) - elif name == 'Has WWS Names': - ufo.info.openTypeOS2Selection.append(8) - continue - # deal with any Glyphs naming quirks here - if name == 'disablesNiceNames': - name = 'useNiceNames' - value = not value - - if name == 'Disable Last Change': - name = 'disablesLastChange' - - # convert code page numbers to OS/2 ulCodePageRange bits - if name == 'codePageRanges': - value = [CODEPAGE_RANGES[v] for v in value] - - # convert Glyphs' GASP Table to UFO openTypeGaspRangeRecords - if name == 'GASP Table': - name = 'openTypeGaspRangeRecords' - # XXX maybe the parser should cast the gasp values to int? - value = {int(k): int(v) for k, v in value.items()} - gasp_records = [] - # gasp range records must be sorted in ascending rangeMaxPPEM - for max_ppem, gasp_behavior in sorted(value.items()): - gasp_records.append({ - 'rangeMaxPPEM': max_ppem, - 'rangeGaspBehavior': bin_to_int_list(gasp_behavior)}) - value = gasp_records - - opentype_attr_prefix_pairs = ( - ('hhea', 'Hhea'), ('description', 'NameDescription'), - ('license', 'NameLicense'), - ('licenseURL', 'NameLicenseURL'), - ('preferredFamilyName', 'NamePreferredFamilyName'), - ('preferredSubfamilyName', 'NamePreferredSubfamilyName'), - ('compatibleFullName', 'NameCompatibleFullName'), - ('sampleText', 'NameSampleText'), - ('WWSFamilyName', 'NameWWSFamilyName'), - ('WWSSubfamilyName', 'NameWWSSubfamilyName'), - ('panose', 'OS2Panose'), - ('typo', 'OS2Typo'), ('unicodeRanges', 'OS2UnicodeRanges'), - ('codePageRanges', 'OS2CodePageRanges'), - ('weightClass', 'OS2WeightClass'), - ('widthClass', 'OS2WidthClass'), - ('win', 'OS2Win'), ('vendorID', 'OS2VendorID'), - ('versionString', 'NameVersion'), ('fsType', 'OS2Type')) - for glyphs_prefix, ufo_prefix in opentype_attr_prefix_pairs: - name = re.sub( - '^' + glyphs_prefix, 'openType' + ufo_prefix, name) - - postscript_attrs = ('underlinePosition', 'underlineThickness') - if name in postscript_attrs: - name = 'postscript' + name[0].upper() + name[1:] - - # enforce that winAscent/Descent are positive, according to UFO spec - if name.startswith('openTypeOS2Win') and value < 0: - value = -value - - # The value of these could be a float or str, and UFO expects an int. - if name in ('openTypeOS2WeightClass', 'openTypeOS2WidthClass', - 'xHeight'): - value = int(value) - - if name == 'glyphOrder': - # store the public.glyphOrder in lib.plist - ufo.lib[PUBLIC_PREFIX + name] = value - elif name in ('PreFilter', 'Filter'): - filter_struct = parse_glyphs_filter( - value, is_pre=name.startswith('Pre')) - if not filter_struct: - continue - if UFO2FT_FILTERS_KEY not in ufo.lib.keys(): - ufo.lib[UFO2FT_FILTERS_KEY] = [] - ufo.lib[UFO2FT_FILTERS_KEY].append(filter_struct) - elif name == "Replace Feature": - tag, repl = re.split("\s*;\s*", value, 1) - ufo.features.text = replace_feature(tag, repl, - ufo.features.text or "") - elif hasattr(ufo.info, name) and name not in non_info: - # most OpenType table entries go in the info object - setattr(ufo.info, name, value) - else: - # everything else gets dumped in the lib - ufo.lib[GLYPHS_PREFIX + name] = value +def to_glyphs_custom_params(self, ufo, glyphs_object): + glyphs_proxy = GlyphsObjectProxy(glyphs_object, + glyphs_module=self.glyphs_module) + ufo_proxy = UFOProxy(ufo) + # Handle known parameters + for handler in KNOWN_PARAM_HANDLERS: + handler.to_glyphs(glyphs_proxy, ufo_proxy) -def set_default_params(ufo): - """ Set Glyphs.app's default parameters when different from ufo2ft ones. - """ - # ufo2ft defaults to fsType Bit 2 ("Preview & Print embedding"), while - # Glyphs.app defaults to Bit 3 ("Editable embedding") - if ufo.info.openTypeOS2Type is None: - ufo.info.openTypeOS2Type = [3] + # Since all UFO `info` entries (from `fontinfo.plist`) have a registered + # handler, the only place where we can find unexpected stuff is the `lib`. + # See the file `tests/builder/fontinfo_test.py` for `fontinfo` coverage. + prefix = CUSTOM_PARAM_PREFIX + glyphs_proxy.sub_key + for name, value in ufo_proxy.unhandled_lib_items(): + name = _normalize_custom_param_name(name) + if not name.startswith(prefix): + continue + name = name[len(prefix):] + glyphs_proxy.set_custom_value(name, value) - # Reference: - # https://glyphsapp.com/content/1-get-started/2-manuals/1-handbook-glyphs-2-0/Glyphs-Handbook-2.3.pdf#page=200 - if ufo.info.postscriptUnderlineThickness is None: - ufo.info.postscriptUnderlineThickness = 50 - if ufo.info.postscriptUnderlinePosition is None: - ufo.info.postscriptUnderlinePosition = -100 + _unset_default_params(glyphs_object) -def normalize_custom_param_name(name): +def _normalize_custom_param_name(name): """Replace curved quotes with straight quotes in a custom parameter name. These should be the only keys with problematic (non-ascii) characters, since they can be user-generated. @@ -184,17 +609,39 @@ def normalize_custom_param_name(name): return name -def parse_custom_params(font, misc_keys): - """Parse customParameters into a list of pairs.""" +DEFAULT_PARAMETERS = ( + # ufo2ft defaults to fsType Bit 2 ("Preview & Print embedding"), while + # Glyphs.app defaults to Bit 3 ("Editable embedding") + ('fsType', 'openTypeOS2Type', [3]), + # Reference: + # https://glyphsapp.com/content/1-get-started/2-manuals/1-handbook-glyphs-2-0/Glyphs-Handbook-2.3.pdf#page=200 + ('underlineThickness', 'postscriptUnderlineThickness', 50), + ('underlinePosition', 'postscriptUnderlinePosition', -100) +) - params = [] - for p in font.customParameters: - params.append((p.name, p.value)) - for key in misc_keys: - try: - val = getattr(font, key) - except AttributeError: - continue - if val is not None: - params.append((key, val)) - return params + +def _set_default_params(ufo): + """ Set Glyphs.app's default parameters when different from ufo2ft ones. + """ + for _, ufo_name, default_value in DEFAULT_PARAMETERS: + if getattr(ufo.info, ufo_name) is None: + if isinstance(default_value, list): + # Prevent problem if the same default value list is put in + # several unrelated objects. + default_value = default_value[:] + setattr(ufo.info, ufo_name, default_value) + + +def _unset_default_params(glyphs): + """ Unset Glyphs.app's parameters that have default values. + FIXME: (jany) maybe this should be taken care of in the writer? and/or + classes should have better default values? + """ + for glyphs_name, ufo_name, default_value in DEFAULT_PARAMETERS: + if (glyphs_name in glyphs.customParameters and + glyphs.customParameters[glyphs_name] == default_value): + del(glyphs.customParameters[glyphs_name]) + # These parameters can be referred to with the two names in Glyphs + if (glyphs_name in glyphs.customParameters and + glyphs.customParameters[glyphs_name] == default_value): + del(glyphs.customParameters[glyphs_name]) diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index 8724001ba..bc0f8c79d 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -15,7 +15,13 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +import re +from textwrap import dedent + from fontTools.misc.py23 import round, unicode +from fontTools.misc.py23 import StringIO + +from fontTools.feaLib import ast, parser import re @@ -23,17 +29,39 @@ from .constants import GLYPHLIB_PREFIX, PUBLIC_PREFIX +ANONYMOUS_FEATURE_PREFIX_NAME = '' +ORIGINAL_FEATURE_CODE_KEY = GLYPHLIB_PREFIX + 'originalFeatureCode' + + def autostr(automatic): return '# automatic\n' if automatic else '' -def to_ufo_features(self, ufo): +def to_ufo_features(self): + for master_id, source in self._sources.items(): + master = self.font.masters[master_id] + _to_ufo_features(self, master, source.font) + + +def _to_ufo_features(self, master, ufo): """Write an UFO's OpenType feature file.""" - prefix_str = '\n\n'.join('# Prefix: %s\n%s%s' % - (prefix.name, autostr(prefix.automatic), - prefix.code.strip()) - for prefix in self.font.featurePrefixes) + # Recover the original feature code if it was stored in the user data + original = master.userData[ORIGINAL_FEATURE_CODE_KEY] + if original is not None: + ufo.features.text = original + return + + prefixes = [] + for prefix in self.font.featurePrefixes: + strings = [] + if prefix.name != ANONYMOUS_FEATURE_PREFIX_NAME: + strings.append('# Prefix: %s\n' % prefix.name) + strings.append(autostr(prefix.automatic)) + strings.append(prefix.code) + prefixes.append(''.join(strings)) + + prefix_str = '\n\n'.join(prefixes) class_defs = [] for class_ in self.font.classes: @@ -45,7 +73,7 @@ def to_ufo_features(self, ufo): feature_defs = [] for feature in self.font.features: - code = feature.code.strip() + code = feature.code lines = ['feature %s {' % feature.name] if feature.notes: lines.append('# notes:') @@ -60,7 +88,11 @@ def to_ufo_features(self, ufo): lines.append('} %s;' % feature.name) feature_defs.append('\n'.join(lines)) fea_str = '\n\n'.join(feature_defs) - gdef_str = _build_gdef(ufo) + + # Don't add a GDEF when planning to round-trip + gdef_str = None + if not self.minimize_glyphs_diffs: + gdef_str = _build_gdef(ufo) # make sure feature text is a unicode string, for defcon full_text = '\n\n'.join( @@ -145,3 +177,404 @@ def replace_feature(tag, repl, features): features, count=1, flags=re.DOTALL | re.MULTILINE) + + +def to_glyphs_features(self): + if not self.designspace.sources: + # Needs at least one UFO + return + + # Handle differing feature files between input UFOs + # For now: switch to very simple strategy if there is any difference + # TODO: (jany) later, use a merge-as-we-go strategy where all discovered + # features go into the GSFont's features, and custom parameters are used + # to disable features on masters that didn't have them originally. + if _features_are_different_across_ufos(self): + if self.minimize_ufo_diffs: + self.logger.warn( + 'Feature files are different across UFOs. The produced Glyphs ' + 'file will have no editable features.') + # Do all UFOs, not only the first one + _to_glyphs_features_basic(self) + return + self.logger.warn( + 'Feature files are different across UFOs. The produced Glyphs ' + 'file will reflect only the features of the first UFO.') + + # Split the feature file of the first UFO into GSFeatures + ufo = self.designspace.sources[0].font + if ufo.features.text is None: + return + document = FeaDocument(ufo.features.text, ufo.keys()) + processor = FeatureFileProcessor(document, self.glyphs_module) + processor.to_glyphs(self.font) + + +def _features_are_different_across_ufos(self): + # FIXME: requires that features are in the same order in all feature files; + # the only allowed differences are whitespace + reference = self.designspace.sources[0].font.features.text or '' + reference = _normalize_whitespace(reference) + for source in self.designspace.sources[1:]: + other = _normalize_whitespace(source.font.features.text or '') + if reference != other: + return True + return False + + +def _normalize_whitespace(text): + # FIXME: does not take into account "significant" whitespace like + # whitespace in a UI string + return re.sub(r'\s+', ' ', text) + + +def _to_glyphs_features_basic(self): + prefix = self.glyphs_module.GSFeaturePrefix() + prefix.name = 'WARNING' + prefix.code = dedent('''\ + # Do not use Glyphs to edit features. + # + # This Glyphs file was made from several UFOs that had different + # features. As a result, the features are not editable in Glyphs and + # the original features will be restored when you go back to UFOs. + ''') + self.font.featurePrefixes.append(prefix) + for master_id, source in self._sources.items(): + master = self.font.masters[master_id] + master.userData[ORIGINAL_FEATURE_CODE_KEY] = source.font.features.text + + +class FeaDocument(object): + """Parse the string of a fea code into statements.""" + def __init__(self, text, glyph_set): + feature_file = StringIO(text) + parser_ = parser.Parser(feature_file, glyph_set, followIncludes=False) + self._doc = parser_.parse() + self.statements = self._doc.statements + self._lines = text.splitlines(True) # keepends=True + self._build_end_locations() + + def text(self, statements): + """Recover the original fea code of the given statements from the + given block. + """ + return ''.join(self._statement_text(st) for st in statements) + + def _statement_text(self, statement): + _, begin_line, begin_char = statement.location + _, end_line, end_char = statement.end_location + lines = self._lines[begin_line - 1:end_line] + if lines: + # In case it's the same line, we need to trim the end first + lines[-1] = lines[-1][:end_char] + lines[0] = lines[0][begin_char - 1:] + return ''.join(lines) + + def _build_end_locations(self): + # The statements in the ast only have their start location, but we also + # need the end location to find the text in between. + # FIXME: (jany) maybe feaLib could provide that? + # Add a fake statement at the end, it's the only one that won't get + # a proper end_location, but its presence will help compute the + # end_location of the real last statement(s). + self._lines.append('#') # Line corresponding to the fake statement + fake_location = (None, len(self._lines), 1) + self._doc.statements.append(ast.Comment(text="Sentinel", location=fake_location)) + self._build_end_locations_rec(self._doc) + # Remove the fake last statement + self._lines.pop() + self._doc.statements.pop() + + def _build_end_locations_rec(self, block): + # To get the end location, we do a depth-first exploration of the ast: + # When a new statement starts, it means that the previous one ended. + # When a new statement starts outside of the current block, we must + # remove the "end-of-block" string from the previous inner statement. + previous = None + previous_in_block = None + for st in block.statements: + if hasattr(st, 'statements'): + self._build_end_locations_rec(st) + if previous is not None: + _, line, char = st.location + line, char = self._previous_char(line, char) + previous.end_location = (None, line, char) + if previous_in_block is not None: + previous_in_block.end_location = self._in_block_end_location( + previous) + previous_in_block = None + previous = st + if hasattr(st, 'statements'): + previous_in_block = st.statements[-1] + + WHITESPACE_RE = re.compile('\\s') + WHITESPACE_OR_NAME_RE = re.compile('\\w|\\s') + + def _previous_char(self, line, char): + char -= 1 + while char == 0: + line -= 1 + char = len(self._lines[line - 1]) + return (line, char) + + def _in_block_end_location(self, block): + _, line, char = block.end_location + + def current_char(line, char): + return self._lines[line - 1][char - 1] + + # Find the semicolon + while current_char(line, char) != ';': + assert self.WHITESPACE_RE.match(current_char(line, char)) + line, char = self._previous_char(line, char) + # Skip it + line, char = self._previous_char(line, char) + # Skip the whitespace and table/feature name + while self.WHITESPACE_OR_NAME_RE.match(current_char(line, char)): + line, char = self._previous_char(line, char) + # It should be the closing bracket + assert current_char(line, char) == '}' + # Skip it and we're done + line, char = self._previous_char(line, char) + + return (None, line, char) + + +class PeekableIterator(object): + """Helper class to iterate and peek over a list.""" + def __init__(self, list): + self.index = 0 + self.list = list + + def has_next(self): + return self.index < len(self.list) + + def next(self): + res = self.list[self.index] + self.index += 1 + return res + + def peek(self, n=0): + return self.list[self.index + n] + + +class FeatureFileProcessor(object): + """Put fea statements into the correct fields of a GSFont.""" + def __init__(self, doc, glyphs_module): + self.doc = doc + self.glyphs_module = glyphs_module + self.statements = PeekableIterator(doc.statements) + self._font = None + + def to_glyphs(self, font): + self._font = font + self._font + self._process_file() + + PREFIX_RE = re.compile('^# Prefix: (.*)$') + AUTOMATIC_RE = re.compile('^# automatic$') + DISABLED_RE = re.compile('^# disabled$') + NOTES_RE = re.compile('^# notes:$') + + def _process_file(self): + unhandled_root_elements = [] + while self.statements.has_next(): + if (self._process_prefix() or + self._process_glyph_class_definition() or + self._process_feature_block() or + self._process_gdef_table_block()): + # Flush any unhandled root elements into an anonymous prefix + if unhandled_root_elements: + prefix = self.glyphs_module.GSFeaturePrefix() + prefix.name = ANONYMOUS_FEATURE_PREFIX_NAME + prefix.code = self._rstrip_newlines( + self.doc.text(unhandled_root_elements)) + self._font.featurePrefixes.append(prefix) + unhandled_root_elements.clear() + else: + # FIXME: (jany) Maybe print warning about unhandled fea block? + unhandled_root_elements.append(self.statements.peek()) + self.statements.next() + # Flush any unhandled root elements into an anonymous prefix + if unhandled_root_elements: + prefix = self.glyphs_module.GSFeaturePrefix() + prefix.name = ANONYMOUS_FEATURE_PREFIX_NAME + prefix.code = self._rstrip_newlines( + self.doc.text(unhandled_root_elements)) + self._font.featurePrefixes.append(prefix) + + def _process_prefix(self): + st = self.statements.peek() + if not isinstance(st, ast.Comment): + return False + match = self.PREFIX_RE.match(st.text) + if not match: + return False + self.statements.next() + + # Consume statements that are part of the feature prefix + prefix_statements = [] + while self.statements.has_next(): + st = self.statements.peek() + # Don't consume statements that are treated specially + if isinstance(st, (ast.GlyphClassDefinition, ast.FeatureBlock, + ast.TableBlock)): + break + # Don't comsume a comment if it is the start of another prefix... + if isinstance(st, ast.Comment): + if self.PREFIX_RE.match(st.text): + break + # ...or if it is the "automatic" comment just before a class + next_st = self.statements.peek(1) + if self.AUTOMATIC_RE.match(st.text) and isinstance( + next_st, ast.GlyphClassDefinition): + break + prefix_statements.append(st) + self.statements.next() + + prefix = self.glyphs_module.GSFeaturePrefix() + prefix.name = match.group(1) + automatic, prefix_statements = self._pop_comment( + prefix_statements, self.AUTOMATIC_RE) + prefix.automatic = bool(automatic) + prefix.code = self._rstrip_newlines( + self.doc.text(prefix_statements), 2) + self._font.featurePrefixes.append(prefix) + return True + + def _process_glyph_class_definition(self): + automatic = False + st = self.statements.peek() + if isinstance(st, ast.Comment): + if self.AUTOMATIC_RE.match(st.text): + automatic = True + st = self.statements.peek(1) + else: + return False + if not isinstance(st, ast.GlyphClassDefinition): + return False + if automatic: + self.statements.next() + self.statements.next() + glyph_class = self.glyphs_module.GSClass() + glyph_class.name = st.name + # Call st.glyphs.asFea() because it updates the 'original' field + # However, we don't use the result of `asFea` because it expands + # classes in a strange way + # FIXME: (jany) maybe open an issue if feaLib? + st.glyphs.asFea() + elements = [] + try: + if st.glyphs.original: + for glyph in st.glyphs.original: + try: + # Class name (GlyphClassName object) + elements.append('@' + glyph.glyphclass.name) + except AttributeError: + try: + # Class name (GlyphClassDefinition object) + # FIXME: (jany) why not always the same type? + elements.append('@' + glyph.name) + except AttributeError: + # Glyph name + elements.append(glyph) + else: + elements = st.glyphSet() + except AttributeError: + # Single class + elements.append('@' + st.glyphs.glyphclass.name) + glyph_class.code = ' '.join(elements) + glyph_class.automatic = bool(automatic) + self._font.classes.append(glyph_class) + return True + + def _process_feature_block(self): + st = self.statements.peek() + if not isinstance(st, ast.FeatureBlock): + return False + self.statements.next() + contents = st.statements + automatic, contents = self._pop_comment( + contents, self.AUTOMATIC_RE) + disabled, disabled_text, contents = self._pop_comment_block( + contents, self.DISABLED_RE) + notes, notes_text, contents = self._pop_comment_block( + contents, self.NOTES_RE) + feature = self.glyphs_module.GSFeature() + feature.name = st.name + feature.automatic = bool(automatic) + if notes: + feature.notes = notes_text + if disabled: + feature.code = disabled_text + # FIXME: (jany) check that the user has not added more new code + # after the disabled comment. Maybe start by checking whether + # the block is only made of comments + else: + feature.code = self._rstrip_newlines(self.doc.text(contents)) + self._font.features.append(feature) + return True + + def _process_gdef_table_block(self): + st = self.statements.peek() + if not isinstance(st, ast.TableBlock) or st.name != 'GDEF': + return False + # TODO: read an existing GDEF table and do something with it? + # For now, this function returns False to say that it has not handled + # the GDEF table, so it will be stored in Glyphs as a prefix with other + # "unhandled root elements". + return False + + def _pop_comment(self, statements, comment_re): + """Look for the comment that matches the given regex. + If it matches, return the regex match object and list of statements + without the special one. + """ + res = [] + match = None + for st in statements: + if match or not isinstance(st, ast.Comment): + res.append(st) + continue + match = comment_re.match(st.text) + if not match: + res.append(st) + return (match, res) + + def _pop_comment_block(self, statements, header_re): + """Look for a series of comments that start with one that matches the + regex. If the first comment is found, all subsequent comments are + popped from statements, concatenated and dedented and returned. + """ + res = [] + comments = [] + match = None + st_iter = iter(statements) + # Look for the header + for st in st_iter: + if isinstance(st, ast.Comment): + match = header_re.match(st.text) + if match: + # Drop this comment an move on to consuming the block + break + else: + res.append(st) + else: + res.append(st) + # Consume consecutive comments + for st in st_iter: + if isinstance(st, ast.Comment): + comments.append(st) + else: + # The block is over, keep the rest of the statements + res.append(st) + break + # Keep the rest of the statements + res.extend(list(st_iter)) + # Inside the comment block, drop the pound sign and any common indent + return (match, dedent(''.join(line[1:] for line in comments)), res) + + def _rstrip_newlines(self, string, number=1): + if len(string) >= number and string[-number:] == '\n' * number: + string = string[:-number] + return string diff --git a/Lib/glyphsLib/builder/filters.py b/Lib/glyphsLib/builder/filters.py index 09018c7d7..fe0b23731 100644 --- a/Lib/glyphsLib/builder/filters.py +++ b/Lib/glyphsLib/builder/filters.py @@ -18,7 +18,7 @@ import logging import re -from glyphsLib.util import cast_to_number_or_bool +from glyphsLib.util import cast_to_number_or_bool, reverse_cast_to_number_or_bool logger = logging.getLogger(__name__) @@ -70,3 +70,18 @@ def parse_glyphs_filter(filter_str, is_pre=False): if is_pre: result['pre'] = True return result + + +def write_glyphs_filter(result): + elements = [result['name']] + if 'args' in result: + for arg in result['args']: + elements.append(reverse_cast_to_number_or_bool(arg)) + if 'kwargs' in result: + for key, arg in result['kwargs'].items(): + if key.lower() not in ('include', 'exclude'): + elements.append(key + ':' + reverse_cast_to_number_or_bool(arg)) + for key, arg in result['kwargs'].items(): + if key.lower() in ('include', 'exclude'): + elements.append(key + ':' + reverse_cast_to_number_or_bool(arg)) + return ';'.join(elements) diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index c209693f0..6d021baa7 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -18,11 +18,15 @@ from collections import deque, OrderedDict import logging -from .common import to_ufo_time -from .constants import GLYPHS_PREFIX +from .common import to_ufo_time, from_ufo_time +from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX logger = logging.getLogger(__name__) +APP_VERSION_LIB_KEY = GLYPHS_PREFIX + 'appVersion' +KEYBOARD_INCREMENT_KEY = GLYPHS_PREFIX + 'keyboardIncrement' +MASTER_ORDER_LIB_KEY = GLYPHS_PREFIX + 'fontMasterOrder' + def to_ufo_font_attributes(self, family_name): """Generate a list of UFOs with metadata loaded from .glyphs data. @@ -45,9 +49,16 @@ def to_ufo_font_attributes(self, family_name): designer_url = font.designerURL manufacturer = font.manufacturer manufacturer_url = font.manufacturerURL + note = font.note + glyph_order = list(glyph.name for glyph in font.glyphs) - for master in font.masters: + 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 if date_created is not None: ufo.info.openTypeHeadCreated = date_created @@ -66,49 +77,21 @@ def to_ufo_font_attributes(self, family_name): if manufacturer_url: ufo.info.openTypeNameManufacturerURL = manufacturer_url - ufo.info.ascender = master.ascender - ufo.info.capHeight = master.capHeight - ufo.info.descender = master.descender - ufo.info.xHeight = master.xHeight - - horizontal_stems = master.horizontalStems - vertical_stems = master.verticalStems - italic_angle = -master.italicAngle - if horizontal_stems: - ufo.info.postscriptStemSnapH = horizontal_stems - if vertical_stems: - ufo.info.postscriptStemSnapV = vertical_stems - if italic_angle: - ufo.info.italicAngle = italic_angle - - width = master.width - weight = master.weight - if weight: - ufo.lib[GLYPHS_PREFIX + 'weight'] = weight - if width: - ufo.lib[GLYPHS_PREFIX + 'width'] = width - for number in ('', '1', '2', '3'): - custom_name = getattr(master, 'customName' + number) - if custom_name: - ufo.lib[GLYPHS_PREFIX + 'customName' + number] = custom_name - custom_value = getattr(master, 'customValue' + number) - if custom_value: - ufo.lib[GLYPHS_PREFIX + 'customValue' + number] = custom_value + ufo.glyphOrder = glyph_order self.to_ufo_names(ufo, master, family_name) - self.to_ufo_blue_values(ufo, master) self.to_ufo_family_user_data(ufo) - self.to_ufo_master_user_data(ufo, master) - self.to_ufo_guidelines(ufo, master) - self.to_ufo_custom_params(ufo, master) + self.to_ufo_custom_params(ufo, font) + + self.to_ufo_master_attributes(source, master) - master_id = master.id - ufo.lib[GLYPHS_PREFIX + 'fontMasterID'] = master_id - # FIXME: (jany) in the future, yield this UFO (for memory, laze iter) - self._ufos[master_id] = ufo + ufo.lib[MASTER_ORDER_LIB_KEY] = index + # FIXME: (jany) in the future, yield this UFO (for memory, lazy iter) + 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`. @@ -118,5 +101,61 @@ def to_glyphs_font_attributes(self, ufo, master, is_initial): master -- The current master being written is_initial -- True iff this the first UFO that we process """ - master.id = ufo.lib[GLYPHS_PREFIX + 'fontMasterID'] - # TODO: all the other attributes + if is_initial: + _set_glyphs_font_attributes(self, source) + else: + _compare_and_merge_glyphs_font_attributes(self, source) + + +def _set_glyphs_font_attributes(self, source): + font = self.font + ufo = source.font + info = ufo.info + + if APP_VERSION_LIB_KEY in ufo.lib: + font.appVersion = ufo.lib[APP_VERSION_LIB_KEY] + if KEYBOARD_INCREMENT_KEY in ufo.lib: + font.keyboardIncrement = ufo.lib[KEYBOARD_INCREMENT_KEY] + + if info.openTypeHeadCreated is not None: + # FIXME: (jany) should wrap in glyphs_datetime? or maybe the GSFont + # should wrap in glyphs_datetime if needed? + font.date = from_ufo_time(info.openTypeHeadCreated) + font.upm = info.unitsPerEm + if info.versionMajor is not None: + font.versionMajor = info.versionMajor + if info.versionMinor is not None: + font.versionMinor = info.versionMinor + + if info.copyright is not None: + font.copyright = info.copyright + if info.openTypeNameDesigner is not None: + font.designer = info.openTypeNameDesigner + if info.openTypeNameDesignerURL is not None: + font.designerURL = info.openTypeNameDesignerURL + if info.openTypeNameManufacturer is not None: + font.manufacturer = info.openTypeNameManufacturer + if info.openTypeNameManufacturerURL is not None: + font.manufacturerURL = info.openTypeNameManufacturerURL + + self.to_glyphs_family_names(ufo) + self.to_glyphs_family_user_data_from_ufo(ufo) + self.to_glyphs_custom_params(ufo, font) + + +def _compare_and_merge_glyphs_font_attributes(self, source): + ufo = source.font + self.to_glyphs_family_names(ufo, merge=True) + + +def to_glyphs_ordered_masters(self): + """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(source): + try: + return source.font.lib[MASTER_ORDER_LIB_KEY] + except KeyError: + return 1 << 31 diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index 479752844..7e1991197 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -17,46 +17,50 @@ import logging logger = logging.getLogger(__name__) -import glyphsLib -from .common import to_ufo_time +from defcon import Color + +import glyphsLib.glyphdata +from .common import to_ufo_time, from_ufo_time, from_loose_ufo_time from .constants import (GLYPHLIB_PREFIX, GLYPHS_COLORS, GLYPHS_PREFIX, PUBLIC_PREFIX) +SCRIPT_LIB_KEY = GLYPHLIB_PREFIX + 'script' +ORIGINAL_WIDTH_KEY = GLYPHLIB_PREFIX + 'originalWidth' +BACKGROUND_WIDTH_KEY = GLYPHLIB_PREFIX + 'backgroundWidth' + -def to_ufo_glyph(self, ufo_glyph, layer, glyph_data): +def to_ufo_glyph(self, ufo_glyph, layer, glyph): """Add .glyphs metadata, paths, components, and anchors to a glyph.""" from glyphsLib import glyphdata # Expensive import - uval = glyph_data.unicode - if uval is not None: - ufo_glyph.unicode = int(uval, 16) - note = glyph_data.note + ufo_glyph.unicodes = [int(uval, 16) for uval in glyph.unicodes] + note = glyph.note if note is not None: ufo_glyph.note = note - last_change = glyph_data.lastChange + last_change = glyph.lastChange if last_change is not None: ufo_glyph.lib[GLYPHLIB_PREFIX + 'lastChange'] = to_ufo_time(last_change) - color_index = glyph_data.color + color_index = glyph.color if color_index is not None: - ufo_glyph.lib[GLYPHLIB_PREFIX + 'ColorIndex'] = color_index color_tuple = None if isinstance(color_index, list): if not all(i in range(0, 256) for i in color_index): logger.warn('Invalid color tuple {} for glyph {}. ' - 'Values must be in range 0-255'.format(color_index, glyph_data.name)) + 'Values must be in range 0-255'.format(color_index, glyph.name)) else: color_tuple = ','.join('{0:.4f}'.format(i/255) if i in range(1, 255) else str(i//255) for i in color_index) elif isinstance(color_index, int) and color_index in range(len(GLYPHS_COLORS)): color_tuple = GLYPHS_COLORS[color_index] else: - logger.warn('Invalid color index {} for {}'.format(color_index, glyph_data.name)) + logger.warn('Invalid color index {} for {}'.format(color_index, glyph.name)) if color_tuple is not None: - ufo_glyph.lib[PUBLIC_PREFIX + 'markColor'] = color_tuple - export = glyph_data.export + ufo_glyph.markColor = color_tuple + export = glyph.export if not export: ufo_glyph.lib[GLYPHLIB_PREFIX + 'Export'] = export + # FIXME: (jany) next line should be an API of GSGlyph? glyphinfo = glyphdata.get_glyph(ufo_glyph.name) - production_name = glyph_data.production or glyphinfo.production_name + production_name = glyph.production or glyphinfo.production_name if production_name != ufo_glyph.name: postscriptNamesKey = PUBLIC_PREFIX + 'postscriptNames' if postscriptNamesKey not in ufo_glyph.font.lib: @@ -64,20 +68,24 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph_data): ufo_glyph.font.lib[postscriptNamesKey][ufo_glyph.name] = production_name for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey']: - glyph_metrics_key = getattr(layer, key) - if glyph_metrics_key is None: - glyph_metrics_key = getattr(glyph_data, key) - if glyph_metrics_key: - ufo_glyph.lib[GLYPHLIB_PREFIX + key] = glyph_metrics_key + value = getattr(layer, key, None) + if value: + ufo_glyph.lib[GLYPHLIB_PREFIX + 'layer.' + key] = value + value = getattr(glyph, key, None) + if value: + ufo_glyph.lib[GLYPHLIB_PREFIX + 'glyph.' + key] = value + + if glyph.script is not None: + ufo_glyph.lib[SCRIPT_LIB_KEY] = glyph.script # if glyph contains custom 'category' and 'subCategory' overrides, store # them in the UFO glyph's lib - category = glyph_data.category + category = glyph.category if category is None: category = glyphinfo.category else: ufo_glyph.lib[GLYPHLIB_PREFIX + 'category'] = category - subCategory = glyph_data.subCategory + subCategory = glyph.subCategory if subCategory is None: subCategory = glyphinfo.subCategory else: @@ -89,87 +97,157 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph_data): pass elif category == 'Mark' and subCategory == 'Nonspacing' and width > 0: # zero the width of Nonspacing Marks like Glyphs.app does on export - # TODO: check for customParameter DisableAllAutomaticBehaviour - ufo_glyph.lib[GLYPHLIB_PREFIX + 'originalWidth'] = width + # TODO: (jany) check for customParameter DisableAllAutomaticBehaviour + # FIXME: (jany) also don't do that when rt UFO -> glyphs -> UFO + ufo_glyph.lib[ORIGINAL_WIDTH_KEY] = width ufo_glyph.width = 0 else: ufo_glyph.width = width - self.to_ufo_glyph_libdata(ufo_glyph, layer) - pen = ufo_glyph.getPointPen() - self.to_ufo_draw_paths(pen, layer.paths) - self.to_ufo_draw_components(pen, layer.components) + self.to_ufo_background_image(ufo_glyph, layer) + self.to_ufo_guidelines(ufo_glyph, layer) + self.to_ufo_glyph_background(ufo_glyph, layer) + self.to_ufo_annotations(ufo_glyph, layer) + self.to_ufo_hints(ufo_glyph, layer) + self.to_ufo_glyph_user_data(ufo_glyph.font, glyph) + self.to_ufo_layer_user_data(ufo_glyph, layer) + self.to_ufo_smart_component_axes(ufo_glyph, glyph) + + self.to_ufo_paths(ufo_glyph, layer) + self.to_ufo_components(ufo_glyph, layer) self.to_ufo_glyph_anchors(ufo_glyph, layer.anchors) -def to_ufo_glyph_background(self, glyph, background): - """Set glyph background.""" +def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): + """Add UFO glif metadata, paths, components, and anchors to a GSGlyph. + If the matching GSGlyph does not exist, then it is created, + else it is updated with the new data. + In all cases, a matching GSLayer is created in the GSGlyph to hold paths. + """ - if not background: - return + # FIXME: (jany) split between glyph and layer attributes + # have a write the first time, compare the next times for glyph + # always write for the layer - if glyph.layer.name != 'public.default': - layer_name = glyph.layer.name + '.background' + if ufo_glyph.name in self.font.glyphs: + glyph = self.font.glyphs[ufo_glyph.name] else: - layer_name = 'public.background' - font = glyph.font - if layer_name not in font.layers: - layer = font.newLayer(layer_name) + glyph = self.glyphs_module.GSGlyph(name=ufo_glyph.name) + # FIXME: (jany) ordering? + self.font.glyphs.append(glyph) + + if ufo_glyph.unicodes: + glyph.unicodes = ['{:04X}'.format(c) for c in ufo_glyph.unicodes] + note = ufo_glyph.note + if note is not None: + glyph.note = note + if GLYPHLIB_PREFIX + 'lastChange' in ufo_glyph.lib: + last_change = ufo_glyph.lib[GLYPHLIB_PREFIX + 'lastChange'] + # We cannot be strict about the dateformat because it's not an official + # UFO field mentioned in the spec so it could happen to have a timezone + glyph.lastChange = from_loose_ufo_time(last_change) + if ufo_glyph.markColor: + glyph.color = _to_glyphs_color(self, ufo_glyph.markColor) + if GLYPHLIB_PREFIX + 'Export' in ufo_glyph.lib: + glyph.export = ufo_glyph.lib[GLYPHLIB_PREFIX + 'Export'] + ps_names_key = PUBLIC_PREFIX + 'postscriptNames' + if (ps_names_key in ufo_glyph.font.lib and + ufo_glyph.name in ufo_glyph.font.lib[ps_names_key]): + glyph.production = ufo_glyph.font.lib[ps_names_key][ufo_glyph.name] + # FIXME: (jany) maybe put something in glyphinfo? No, it's readonly + # maybe don't write in glyph.production if glyphinfo already + # has something + # glyphinfo = glyphsLib.glyphdata.get_glyph(ufo_glyph.name) + # production_name = glyph.production or glyphinfo.production_name + + glyphinfo = glyphsLib.glyphdata.get_glyph(ufo_glyph.name) + + layer = self.to_glyphs_layer(ufo_layer, glyph, master) + + for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey']: + # Also read the old version of the key that didn't have a prefix and + # store it on the layer (because without the "glyph"/"layer" prefix we + # didn't know whether it originally came from the layer of the glyph, + # so it's easier to put it back on the most specific level, i.e. the + # layer) + for prefix, glyphs_object in (('glyph.', glyph), + ('', layer), + ('layer.', layer)): + full_key = GLYPHLIB_PREFIX + prefix + key + if full_key in ufo_glyph.lib: + value = ufo_glyph.lib[full_key] + setattr(glyphs_object, key, value) + + if SCRIPT_LIB_KEY in ufo_glyph.lib: + glyph.script = ufo_glyph.lib[SCRIPT_LIB_KEY] + + if GLYPHLIB_PREFIX + 'category' in ufo_glyph.lib: + # TODO: (jany) store category only if different from glyphinfo? + category = ufo_glyph.lib[GLYPHLIB_PREFIX + 'category'] + glyph.category = category else: - layer = font.layers[layer_name] - new_glyph = layer.newGlyph(glyph.name) - new_glyph.width = glyph.width - pen = new_glyph.getPointPen() - self.to_ufo_draw_paths(pen, background.paths) - self.to_ufo_draw_components(pen, background.components) + category = glyphinfo.category + if GLYPHLIB_PREFIX + 'subCategory' in ufo_glyph.lib: + sub_category = ufo_glyph.lib[GLYPHLIB_PREFIX + 'subCategory'] + glyph.subCategory = sub_category + else: + sub_category = glyphinfo.subCategory + + # load width before background, which is loaded with lib data + if hasattr(layer, 'foreground'): + if ufo_glyph.width: + # Don't store "0", it's the default in UFO. + # Store in userData because the background's width is not relevant + # in Glyphs. + layer.userData[BACKGROUND_WIDTH_KEY] = ufo_glyph.width + else: + layer.width = ufo_glyph.width + if category == 'Mark' and sub_category == 'Nonspacing' and layer.width == 0: + # Restore originalWidth + if ORIGINAL_WIDTH_KEY in ufo_glyph.lib: + layer.width = ufo_glyph.lib[ORIGINAL_WIDTH_KEY] + # TODO: (jany) check for customParam DisableAllAutomaticBehaviour? + + self.to_glyphs_background_image(ufo_glyph, layer) + self.to_glyphs_guidelines(ufo_glyph, layer) + self.to_glyphs_annotations(ufo_glyph, layer) + self.to_glyphs_hints(ufo_glyph, layer) + self.to_glyphs_glyph_user_data(ufo_glyph.font, glyph) + self.to_glyphs_layer_user_data(ufo_glyph, layer) + self.to_glyphs_smart_component_axes(ufo_glyph, glyph) + + self.to_glyphs_paths(ufo_glyph, layer) + self.to_glyphs_components(ufo_glyph, layer) + self.to_glyphs_glyph_anchors(ufo_glyph, layer) + + +def to_ufo_glyph_background(self, glyph, layer): + """Set glyph background.""" + + if not layer.hasBackground: + return + + background = layer.background + ufo_layer = self.to_ufo_background_layer(glyph) + new_glyph = ufo_layer.newGlyph(glyph.name) + + width = background.userData[BACKGROUND_WIDTH_KEY] + if width is not None: + new_glyph.width = width + + self.to_ufo_background_image(new_glyph, background) + self.to_ufo_paths(new_glyph, background) + self.to_ufo_components(new_glyph, background) self.to_ufo_glyph_anchors(new_glyph, background.anchors) self.to_ufo_guidelines(new_glyph, background) -def to_ufo_glyph_libdata(self, glyph, layer): - """Add to a glyph's lib data.""" - - self.to_ufo_guidelines(glyph, layer) - self.to_ufo_glyph_background(glyph, layer.background) - for key in ['annotations', 'hints']: - try: - value = getattr(layer, key) - except KeyError: - continue - if key == 'annotations': - annotations = [] - for an in list(value.values()): - annot = {} - for attr in ['angle', 'position', 'text', 'type', 'width']: - val = getattr(an, attr, None) - if attr == 'position' and val: - val = list(val) - if val: - annot[attr] = val - annotations.append(annot) - value = annotations - elif key == 'hints': - hints = [] - for hi in value: - hint = {} - for attr in ['horizontal', 'options', 'stem', 'type']: - val = getattr(hi, attr, None) - hint[attr] = val - for attr in ['origin', 'other1', 'other2', 'place', 'scale', - 'target']: - val = getattr(hi, attr, None) - if val is not None and not any(v is None for v in val): - hint[attr] = list(val) - hints.append(hint) - value = hints +def _to_glyphs_color(self, color): + # color is a defcon Color + # Try to find a matching Glyphs color + for index, glyphs_color in enumerate(GLYPHS_COLORS): + if str(color) == glyphs_color: + return index - if value: - glyph.lib[GLYPHS_PREFIX + key] = value - - # data related to components stored in lists of booleans - # each list's elements correspond to the components in order - for key in ['alignment', 'locked']: - values = [getattr(c, key) for c in layer.components] - if any(values): - key = key[0].upper() + key[1:] - glyph.lib['%scomponents%s' % (GLYPHS_PREFIX, key)] = values + # Otherwise, make a Glyphs-formatted color list + return [round(component * 255) for component in list(color)] diff --git a/Lib/glyphsLib/builder/groups.py b/Lib/glyphsLib/builder/groups.py new file mode 100644 index 000000000..6ae43b011 --- /dev/null +++ b/Lib/glyphsLib/builder/groups.py @@ -0,0 +1,191 @@ +# Copyright 2016 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 defaultdict +import os +import re + +from glyphsLib import classes +from .constants import GLYPHLIB_PREFIX + +UFO_ORIGINAL_KERNING_GROUPS_KEY = GLYPHLIB_PREFIX + 'originalKerningGroups' +UFO_GROUPS_NOT_IN_FEATURE_KEY = GLYPHLIB_PREFIX + 'groupsNotInFeature' +UFO_KERN_GROUP_PATTERN = re.compile('^public\\.kern([12])\\.(.*)$') + + +def to_ufo_groups(self): + # Build groups once and then apply to all UFOs. + groups = defaultdict(list) + + # Classes usually go to the feature file, unless we have our custom flag + group_names = None + if UFO_GROUPS_NOT_IN_FEATURE_KEY in self.font.userData.keys(): + group_names = set(self.font.userData[UFO_GROUPS_NOT_IN_FEATURE_KEY]) + if group_names: + for gsclass in self.font.classes.values(): + if gsclass.name in group_names: + if gsclass.code: + groups[gsclass.name] = gsclass.code.split(' ') + else: + # Empty group: using split like above would produce [''] + groups[gsclass.name] = [] + + # Rebuild kerning groups from `left/rightKerningGroup`s + # Use the original list of kerning groups as a base, to recover + # - the original ordering + # - the kerning groups of glyphs that were not in the font (which can be + # stored in a UFO but not by Glyphs) + recovered = set() + orig_groups = self.font.userData.get(UFO_ORIGINAL_KERNING_GROUPS_KEY) + if orig_groups: + for group, glyphs in orig_groups.items(): + if not glyphs: + # Restore empty group + groups[group] = [] + for glyph_name in glyphs: + # Check that the original value is still valid + match = UFO_KERN_GROUP_PATTERN.match(group) + side = match.group(1) + group_name = match.group(2) + glyph = self.font.glyphs[glyph_name] + if not glyph or getattr( + glyph, _glyph_kerning_attr(glyph, side)) == group_name: + # The original grouping is still valid + groups[group].append(glyph_name) + # Remember not to add this glyph again later + # Thus the original position in the list is preserved + recovered.add((glyph_name, int(side))) + + # Read modified grouping values + for glyph in self.font.glyphs.values(): + for side in 1, 2: + if (glyph.name, side) not in recovered: + attr = _glyph_kerning_attr(glyph, side) + group = getattr(glyph, attr) + if group: + group = 'public.kern%s.%s' % (side, group) + groups[group].append(glyph.name) + + # Update all UFOs with the same info + for source in self._sources.values(): + for name, glyphs in groups.items(): + # Shallow copy to prevent unexpected object sharing + source.font.groups[name] = glyphs[:] + + +def to_glyphs_groups(self): + # Build the GSClasses from the groups of the first UFO. + groups = [] + 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: + gsclass = classes.GSClass(name, " ".join(glyphs)) + self.font.classes.append(gsclass) + groups.append(name) + if self.minimize_ufo_diffs: + self.font.userData[UFO_GROUPS_NOT_IN_FEATURE_KEY] = groups + break + + # Check that other UFOs are identical and print a warning if not. + for index, source in enumerate(self._sources.values()): + if index == 0: + reference_ufo = source.font + else: + _assert_groups_are_identical(self, reference_ufo, source.font) + + +def _is_kerning_group(name): + return (name.startswith('public.kern1.') or + name.startswith('public.kern2.')) + + +def _to_glyphs_kerning_group(self, name, glyphs): + if self.minimize_ufo_diffs: + # Preserve ordering when going from UFO group + # to left/rightKerningGroup disseminated in GSGlyphs + # back to UFO group. + if not self.font.userData.get(UFO_ORIGINAL_KERNING_GROUPS_KEY): + self.font.userData[UFO_ORIGINAL_KERNING_GROUPS_KEY] = {} + self.font.userData[UFO_ORIGINAL_KERNING_GROUPS_KEY][name] = glyphs + + match = UFO_KERN_GROUP_PATTERN.match(name) + side = match.group(1) + group_name = match.group(2) + for glyph_name in glyphs: + glyph = self.font.glyphs[glyph_name] + if glyph: + setattr(glyph, _glyph_kerning_attr(glyph, side), group_name) + + +def _glyph_kerning_attr(glyph, side): + """Return leftKerningGroup or rightKerningGroup depending on the UFO + group's side (1 or 2) and the glyph's direction (LTR or RTL). + """ + side = int(side) + if _is_ltr(glyph): + if side == 1: + return 'rightKerningGroup' + else: + return 'leftKerningGroup' + else: + # RTL + if side == 1: + return 'leftKerningGroup' + else: + return 'rightKerningGroup' + + +def _is_ltr(glyph): + # TODO: (jany) This needs a real implementation! + # The following one is just to make my simple test pass + if glyph.name.endswith('-hb'): + return False + return True + + +def _assert_groups_are_identical(self, reference_ufo, ufo): + first_time = [True] # Using a mutable as a non-local for closure below + + def _warn(message, *args): + if first_time: + self.logger.warn('Using UFO `%s` as a reference for groups:', + _ufo_logging_ref(reference_ufo)) + first_time.clear() + self.logger.warn(' ' + message, *args) + + # Check for inconsistencies + for group, glyphs in ufo.groups.items(): + if group not in reference_ufo.groups: + _warn("group `%s` from `%s` will be lost because it's not " + "defined in the reference UFO", group, _ufo_logging_ref(ufo)) + continue + reference_glyphs = reference_ufo.groups[group] + if set(glyphs) != set(reference_glyphs): + _warn("group `%s` from `%s` will not be stored accurately because " + "it is different from the reference UFO", group, + _ufo_logging_ref(ufo)) + _warn(" reference = %s", ' '.join(sorted(glyphs))) + _warn(" current = %s", ' '.join(sorted(reference_glyphs))) + + +def _ufo_logging_ref(ufo): + """Return a string that can identify this UFO in logs.""" + if ufo.path: + return os.path.basename(ufo.path) + return ufo.info.styleName diff --git a/Lib/glyphsLib/builder/guidelines.py b/Lib/glyphsLib/builder/guidelines.py index 58a3c29cc..fa09fea22 100644 --- a/Lib/glyphsLib/builder/guidelines.py +++ b/Lib/glyphsLib/builder/guidelines.py @@ -15,6 +15,16 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +import re + +from glyphsLib.types import Point + +COLOR_NAME_SUFFIX = ' [%s]' +COLOR_NAME_RE = re.compile(r'^.*( \[([0-1.,eE+-]+)\])$') +IDENTIFIER_NAME_SUFFIX = ' [#%s]' +IDENTIFIER_NAME_RE = re.compile(r'^.*( \[#([^\]]+)\])$') +LOCKED_NAME_SUFFIX = ' [locked]' + def to_ufo_guidelines(self, ufo_obj, glyphs_obj): """Set guidelines.""" @@ -23,14 +33,67 @@ def to_ufo_guidelines(self, ufo_obj, glyphs_obj): return new_guidelines = [] for guideline in guidelines: - + new_guideline = {} x, y = guideline.position angle = guideline.angle - new_guideline = {'x': x, 'y': y, 'angle': (360 - angle) % 360} + angle = (360 - angle) % 360 + if _is_vertical(x, y, angle): + new_guideline['x'] = x + elif _is_horizontal(x, y, angle): + new_guideline['y'] = y + else: + new_guideline['x'] = x + new_guideline['y'] = y + new_guideline['angle'] = angle + name = guideline.name + if name is not None: + # Identifier + m = IDENTIFIER_NAME_RE.match(name) + if m: + new_guideline['identifier'] = m.group(2) + name = name[:-len(m.group(1))] + # Color + m = COLOR_NAME_RE.match(name) + if m: + new_guideline['color'] = m.group(2) + name = name[:-len(m.group(1))] + if guideline.locked: + name = (name or '') + LOCKED_NAME_SUFFIX + if name: + new_guideline['name'] = name new_guidelines.append(new_guideline) ufo_obj.guidelines = new_guidelines -def to_glyphs_guidelines(self, glyphs_obj, ufo_obj): +def to_glyphs_guidelines(self, ufo_obj, glyphs_obj): """Set guidelines.""" - pass + if not ufo_obj.guidelines: + return + for guideline in ufo_obj.guidelines: + new_guideline = self.glyphs_module.GSGuideLine() + name = guideline.name + # Locked + if name is not None and name.endswith(LOCKED_NAME_SUFFIX): + name = name[:-len(LOCKED_NAME_SUFFIX)] + new_guideline.locked = True + if guideline.color: + name = (name or '') + COLOR_NAME_SUFFIX % str(guideline.color) + if guideline.identifier: + name = (name or '') + IDENTIFIER_NAME_SUFFIX % guideline.identifier + new_guideline.name = name + new_guideline.position = Point(guideline.x or 0, guideline.y or 0) + if guideline.angle is not None: + new_guideline.angle = (360 - guideline.angle) % 360 + elif _is_vertical(guideline.x, guideline.y, None): + new_guideline.angle = 90 + glyphs_obj.guides.append(new_guideline) + + +def _is_vertical(x, y, angle): + return (y is None or y == 0) and ( + angle is None or angle == 90 or angle == 270) + + +def _is_horizontal(x, y, angle): + return (x is None or x == 0) and ( + angle is None or angle == 0 or angle == 180) diff --git a/Lib/glyphsLib/builder/hints.py b/Lib/glyphsLib/builder/hints.py new file mode 100644 index 000000000..687bbb2f8 --- /dev/null +++ b/Lib/glyphsLib/builder/hints.py @@ -0,0 +1,59 @@ +# Copyright 2016 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 .constants import GLYPHS_PREFIX +from glyphsLib.types import Point + +LIB_KEY = GLYPHS_PREFIX + 'hints' + + +def to_ufo_hints(self, ufo_glyph, layer): + try: + value = layer.hints + except KeyError: + return + hints = [] + for hi in value: + hint = {} + for attr in ['horizontal', 'options', 'stem', 'type']: + val = getattr(hi, attr, None) + hint[attr] = val + for attr in ['origin', 'other1', 'other2', 'place', 'scale', 'target']: + val = getattr(hi, attr, None) + # FIXME: (jany) what about target = up/down? + if val is not None and not any(v is None for v in val): + hint[attr] = list(val) + hints.append(hint) + + if hints: + ufo_glyph.lib[LIB_KEY] = hints + + +def to_glyphs_hints(self, ufo_glyph, layer): + if LIB_KEY not in ufo_glyph.lib: + return + for hint in ufo_glyph.lib[LIB_KEY]: + hi = self.glyphs_module.GSHint() + for attr in ['horizontal', 'options', 'stem', 'type']: + setattr(hi, attr, hint[attr]) + for attr in ['origin', 'other1', 'other2', 'place', 'scale', 'target']: + # FIXME: (jany) what about target = up/down? + if attr in hint: + value = Point(*hint[attr]) + setattr(hi, attr, value) + layer.hints.append(hi) diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py new file mode 100644 index 000000000..af7084f69 --- /dev/null +++ b/Lib/glyphsLib/builder/instances.py @@ -0,0 +1,348 @@ +# 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 +import os + +from glyphsLib.util import build_ufo_path +from glyphsLib.classes import WEIGHT_CODES, GSCustomParameter +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, + WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF) +from .custom_params import to_ufo_custom_params + +EXPORT_KEY = GLYPHS_PREFIX + 'export' +WIDTH_KEY = GLYPHS_PREFIX + 'width' +WEIGHT_KEY = GLYPHS_PREFIX + 'weight' +FULL_FILENAME_KEY = GLYPHLIB_PREFIX + 'fullFilename' +MANUAL_INTERPOLATION_KEY = GLYPHS_PREFIX + 'manualInterpolation' +INSTANCE_INTERPOLATIONS_KEY = GLYPHS_PREFIX + 'intanceInterpolations' +CUSTOM_PARAMETERS_KEY = GLYPHS_PREFIX + 'customParameters' + + +def to_designspace_instances(self): + """Write instance data from self.font to self.designspace.""" + for instance in self.font.instances: + if (self.minimize_glyphs_diffs or + (is_instance_active(instance) and + _is_instance_included_in_family(self, instance))): + _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': + ufo_instance.familyName = value + elif param == 'postscriptFontName': + # Glyphs uses "postscriptFontName", not "postScriptFontName" + ufo_instance.postScriptFontName = value + elif param == 'fileName': + 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: (jany) investigate the possibility of storing a relative path in + # the `filename` custom parameter. If yes, drop the key below. + # Maybe do the same for masters? + # https://github.com/googlei18n/glyphsLib/issues/319 + 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: + instance_dir = self.instance_dir or '.' + ufo_instance.filename = build_ufo_path( + instance_dir, ufo_instance.familyName, ufo_instance.styleName) + + designspace_axis_tags = set(a.tag for a in self.designspace.axes) + location = {} + for axis_def in get_axis_definitions(self.font): + # Only write locations along defined axes + if axis_def.tag in designspace_axis_tags: + location[axis_def.name] = axis_def.get_design_loc(instance) + ufo_instance.location = location + + # 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 + + # Strategy: dump all custom parameters into the InstanceDescriptor. + # Later, when using `glyphsLib.interpolation.apply_instance_data`, + # we will dig out those custom parameters using + # `InstanceDescriptorAsGSInstance` and apply them to the instance UFO + # with `to_ufo_custom_params`. + # NOTE: customParameters are not a dict! One key can have several values + params = [] + for p in instance.customParameters: + if p.name in ('familyName', 'postscriptFontName', 'fileName', + FULL_FILENAME_KEY): + # These will be stored in the official descriptor attributes + continue + if p.name in ('weightClass', 'widthClass'): + # No need to store these ones because we can recover them by + # reading the mapping backward, because the mapping is built from + # where the instances are. + continue + params.append((p.name, p.value)) + if params: + ufo_instance.lib[CUSTOM_PARAMETERS_KEY] = params + + self.designspace.addInstance(ufo_instance) + + +def _is_instance_included_in_family(self, instance): + if not self._do_filter_instances_by_family: + return True + return instance.familyName == self.family_name + + +def to_glyphs_instances(self): + if self.designspace is None: + return + + for ufo_instance in self.designspace.instances: + instance = self.glyphs_module.GSInstance() + + try: + instance.active = ufo_instance.lib[EXPORT_KEY] + except KeyError: + # If not specified, the default is to export all instances + instance.active = True + + 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) + # Generic way: read the axis mapping backwards. + 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: + # Restore the original weight name 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] + # Only use the lib value if: + # 1. we don't have a weight for the instance already + # 2. the value from lib is not "stale", i.e. it still maps to + # the current userLocation of the instance. This is in case the + # user changes the instance location of the instance by hand but + # does not update the weight value in lib. + if (not instance.weight or + WEIGHT_CODES[instance.weight] == WEIGHT_CODES[weight]): + instance.weight = weight + except KeyError: + # FIXME: what now + pass + + try: + if not instance.width: + instance.width = ufo_instance.lib[WIDTH_KEY] + except KeyError: + # FIXME: what now + pass + + if ufo_instance.familyName is not None: + if ufo_instance.familyName != self.font.familyName: + instance.familyName = ufo_instance.familyName + + 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[ + MANUAL_INTERPOLATION_KEY] + except KeyError: + pass + + try: + instance.instanceInterpolations = ufo_instance.lib[ + INSTANCE_INTERPOLATIONS_KEY] + except KeyError: + # TODO: (jany) compute instanceInterpolations from the location + # if instance.manualInterpolation: warn about data loss + pass + + if CUSTOM_PARAMETERS_KEY in ufo_instance.lib: + for name, value in ufo_instance.lib[CUSTOM_PARAMETERS_KEY]: + instance.customParameters.append( + GSCustomParameter(name, value)) + + 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] + + +class InstanceDescriptorAsGSInstance(object): + """Wraps a designspace InstanceDescriptor and makes it behave like a + GSInstance, just enough to use the descriptor as a source of custom + parameters for `to_ufo_custom_parameters` + """ + def __init__(self, descriptor): + self._descriptor = descriptor + + # Having a simple list is enough because `to_ufo_custom_params` does + # not use the fake dictionary interface. + self.customParameters = [] + if CUSTOM_PARAMETERS_KEY in descriptor.lib: + for name, value in descriptor.lib[CUSTOM_PARAMETERS_KEY]: + self.customParameters.append(GSCustomParameter(name, value)) + + +def _set_class_from_instance(ufo, designspace, instance, axis_def): + # FIXME: (jany) copy-pasted from above, factor into method? + design_loc = None + try: + design_loc = instance.location[axis_def.name] + except KeyError: + # The location does not have this axis? + pass + + # Retrieve the user location (weightClass/widthClass) + # by going through the axis mapping in reverse. + user_loc = design_loc + mapping = None + for axis in 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_ufo_user_loc(ufo, user_loc) + else: + axis_def.set_ufo_user_loc(ufo, axis_def.default_user_loc) + + +def set_weight_class(ufo, designspace, instance): + """ the `weightClass` instance attribute from the UFO lib, and set + the ufo.info.openTypeOS2WeightClass accordingly. + """ + _set_class_from_instance(ufo, designspace, instance, WEIGHT_AXIS_DEF) + + +def set_width_class(ufo, designspace, instance): + """Read the `widthClass` instance attribute from the UFO lib, and set the + ufo.info.openTypeOS2WidthClass accordingly. + """ + _set_class_from_instance(ufo, designspace, instance, WIDTH_AXIS_DEF) + + +# DEPRECATED: needs better API +def apply_instance_data(instance_data): + """Open instances, apply data, and re-save. + + Args: + instance_data: an InstanceData object. + Returns: + List of opened and updated instance UFOs. + """ + import defcon + designspace = instance_data.designspace + + instance_ufos = [] + for instance in designspace.instances: + path = instance.path + if path is None: + path = os.path.join( + os.path.dirname(designspace.path), instance.filename) + ufo = defcon.Font(path) + set_weight_class(ufo, designspace, instance) + set_width_class(ufo, designspace, instance) + + glyphs_instance = InstanceDescriptorAsGSInstance(instance) + # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? + to_ufo_custom_params(None, ufo, glyphs_instance) + ufo.save() + instance_ufos.append(ufo) + return instance_ufos + + +# DEPRECATED: supports deprecated APIs +class InstanceData(list): + """A list wrapper that also holds a reference to a designspace. + It's only here to accomodate for existing APIs. + """ + def __init__(self, designspace): + self.designspace = designspace + self.extend((i.path, i) for i in designspace.instances) diff --git a/Lib/glyphsLib/builder/kerning.py b/Lib/glyphsLib/builder/kerning.py index 4c3f85b45..7581afe0c 100644 --- a/Lib/glyphsLib/builder/kerning.py +++ b/Lib/glyphsLib/builder/kerning.py @@ -15,13 +15,17 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -import logging import re -logger = logging.getLogger(__name__) +UFO_KERN_GROUP_PATTERN = re.compile('^public\\.kern([12])\\.(.*)$') -def to_ufo_kerning(self, ufo, kerning_data): +def to_ufo_kerning(self): + for master_id, kerning in self.font.kerning.items(): + _to_ufo_kerning(self, self._sources[master_id].font, kerning) + + +def _to_ufo_kerning(self, ufo, kerning_data): """Add .glyphs kerning to an UFO.""" warning_msg = 'Non-existent glyph class %s found in kerning rules.' @@ -33,16 +37,16 @@ def to_ufo_kerning(self, ufo, kerning_data): if left_is_class: left = 'public.kern1.%s' % match.group(1) if left not in ufo.groups: - logger.warn(warning_msg % left) - continue + # self.logger.warn(warning_msg % left) + pass for right, kerning_val in pairs.items(): match = re.match(r'@MMK_R_(.+)', right) right_is_class = bool(match) if right_is_class: right = 'public.kern2.%s' % match.group(1) if right not in ufo.groups: - logger.warn(warning_msg % right) - continue + # self.logger.warn(warning_msg % right) + pass if left_is_class != right_is_class: if left_is_class: pair = (left, right, True) @@ -53,27 +57,32 @@ def to_ufo_kerning(self, ufo, kerning_data): seen = {} for classname, glyph, is_left_class in reversed(class_glyph_pairs): - _remove_rule_if_conflict(ufo, seen, classname, glyph, is_left_class) + _remove_rule_if_conflict(self, ufo, seen, classname, glyph, + is_left_class) -def _remove_rule_if_conflict(ufo, seen, classname, glyph, is_left_class): +def _remove_rule_if_conflict(self, ufo, seen, classname, glyph, is_left_class): """Check if a class-to-glyph kerning rule has a conflict with any existing rule in `seen`, and remove any conflicts if they exist. """ - original_pair = (classname, glyph) if is_left_class else (glyph, classname) val = ufo.kerning[original_pair] rule = original_pair + (val,) - old_glyphs = ufo.groups[classname] + try: + old_glyphs = ufo.groups[classname] + except KeyError: + # This can happen. The main function `to_ufo_kerning` prints a warning. + return + new_glyphs = [] for member in old_glyphs: pair = (member, glyph) if is_left_class else (glyph, member) existing_rule = seen.get(pair) if (existing_rule is not None and - existing_rule[-1] != val and - pair not in ufo.kerning): - logger.warn( + existing_rule[-1] != val and + pair not in ufo.kerning): + self.logger.warn( 'Conflicting kerning rules found in %s master for glyph pair ' '"%s, %s" (%s and %s), removing pair from latter rule' % ((ufo.info.styleName,) + pair + (existing_rule, rule))) @@ -88,23 +97,15 @@ def _remove_rule_if_conflict(ufo, seen, classname, glyph, is_left_class): ufo.kerning[pair] = val -def to_ufo_glyph_groups(self, kerning_groups, glyph_data): - """Add a glyph to its kerning groups, creating new groups if necessary.""" - - glyph_name = glyph_data.name - group_keys = { - '1': 'rightKerningGroup', - '2': 'leftKerningGroup'} - for side, group_key in group_keys.items(): - group = getattr(glyph_data, group_key) - if group is None or len(group) == 0: - continue - group = 'public.kern%s.%s' % (side, group) - kerning_groups[group] = kerning_groups.get(group, []) + [glyph_name] - - -def to_ufo_kerning_groups(self, ufo, kerning_groups): - """Add kerning groups to an UFO.""" - - for name, glyphs in kerning_groups.items(): - ufo.groups[name] = glyphs +def to_glyphs_kerning(self): + """Add UFO kerning to GSFont.""" + 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: + left = '@MMK_L_{}'.format(left_match.group(2)) + if right_match: + right = '@MMK_R_{}'.format(right_match.group(2)) + self.font.setKerningForPair(master_id, left, right, value) + # FIXME: (jany) handle conflicts? diff --git a/Lib/glyphsLib/builder/layers.py b/Lib/glyphsLib/builder/layers.py new file mode 100644 index 000000000..38cf75605 --- /dev/null +++ b/Lib/glyphsLib/builder/layers.py @@ -0,0 +1,123 @@ +# 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 .constants import GLYPHS_PREFIX + +LAYER_ID_KEY = GLYPHS_PREFIX + 'layerId' +LAYER_ORDER_PREFIX = GLYPHS_PREFIX + 'layerOrderInGlyph.' +LAYER_ORDER_TEMP_USER_DATA_KEY = '__layerOrder' + + +def to_ufo_layer(self, glyph, layer): + ufo_font = self._sources[layer.associatedMasterId or layer.layerId].font + if layer.associatedMasterId == layer.layerId: + ufo_layer = ufo_font.layers.defaultLayer + elif layer.name not in ufo_font.layers: + ufo_layer = ufo_font.newLayer(layer.name) + else: + ufo_layer = ufo_font.layers[layer.name] + if self.minimize_glyphs_diffs: + ufo_layer.lib[LAYER_ID_KEY] = layer.layerId + ufo_layer.lib[LAYER_ORDER_PREFIX + glyph.name] = _layer_order_in_glyph( + self, layer) + return ufo_layer + + +def to_ufo_background_layer(self, ufo_glyph): + font = self.font + if ufo_glyph.layer.name != 'public.default': + layer_name = ufo_glyph.layer.name + '.background' + else: + layer_name = 'public.background' + font = ufo_glyph.font + if layer_name not in font.layers: + ufo_layer = font.newLayer(layer_name) + else: + ufo_layer = font.layers[layer_name] + return ufo_layer + + +def _layer_order_in_glyph(self, layer): + # TODO: optimize? + for order, glyph_layer in enumerate(layer.parent.layers.values()): + if glyph_layer is layer: + return order + return None + + +def to_glyphs_layer(self, ufo_layer, glyph, master): + if ufo_layer.name == 'public.default': # TODO: (jany) constant + layer = _get_or_make_foreground(self, glyph, master) + elif ufo_layer.name == 'public.background': + master_layer = _get_or_make_foreground(self, glyph, master) + layer = master_layer.background + elif ufo_layer.name.endswith('.background'): + # Find or create the foreground layer + # TODO: (jany) add lib attribute to find foreground by layer id + foreground_name = ufo_layer.name[:-len('.background')] + foreground = next( + (l for l in glyph.layers + if l.name == foreground_name and l.associatedMasterId == master.id + ), None) + if foreground is None: + foreground = self.glyphs_module.GSLayer() + foreground.name = foreground_name + foreground.associatedMasterId = master.id + layer = foreground.background + # Background layers don't have an associated master id nor a name nor an id + else: + layer = next(( + l for l in glyph.layers + if l.name == ufo_layer.name and l.associatedMasterId == master.id), + None) + if layer is None: + layer = self.glyphs_module.GSLayer() + layer.associatedMasterId = master.id + if LAYER_ID_KEY in ufo_layer.lib: + layer.layerId = ufo_layer.lib[LAYER_ID_KEY] + layer.name = ufo_layer.name + glyph.layers.append(layer) + order_key = LAYER_ORDER_PREFIX + glyph.name + if order_key in ufo_layer.lib: + order = ufo_layer.lib[order_key] + layer.userData[LAYER_ORDER_TEMP_USER_DATA_KEY] = order + return layer + + +def _get_or_make_foreground(self, glyph, master): + layer = glyph.layers[master.id] + if layer is None: + layer = glyph.layers[master.id] = self.glyphs_module.GSLayer() + layer.layerId = master.id + layer.name = master.name + return layer + + +def to_glyphs_layer_order(self, glyph): + # TODO: (jany) ask for the rules of layer ordering inside a glyph + # For now, order according to key in lib + glyph.layers = sorted(glyph.layers, key=_layer_order) + for layer in glyph.layers: + if LAYER_ORDER_TEMP_USER_DATA_KEY in layer.userData: + del(layer.userData[LAYER_ORDER_TEMP_USER_DATA_KEY]) + + +def _layer_order(layer): + if LAYER_ORDER_TEMP_USER_DATA_KEY in layer.userData: + return layer.userData[LAYER_ORDER_TEMP_USER_DATA_KEY] + return float('inf') diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py new file mode 100644 index 000000000..bd1d78fa6 --- /dev/null +++ b/Lib/glyphsLib/builder/masters.py @@ -0,0 +1,117 @@ +# 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 collections import OrderedDict + +from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX + +MASTER_ID_LIB_KEY = GLYPHS_PREFIX + 'fontMasterID' +UFO_FILENAME_KEY = GLYPHLIB_PREFIX + 'ufoFilename' +UFO_YEAR_KEY = GLYPHLIB_PREFIX + 'ufoYear' +UFO_NOTE_KEY = GLYPHLIB_PREFIX + 'ufoNote' + + +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 + ufo.info.xHeight = master.xHeight + + horizontal_stems = master.horizontalStems + vertical_stems = master.verticalStems + italic_angle = -master.italicAngle + if horizontal_stems: + ufo.info.postscriptStemSnapH = horizontal_stems + if vertical_stems: + ufo.info.postscriptStemSnapV = vertical_stems + if italic_angle: + ufo.info.italicAngle = italic_angle + + year = master.userData[UFO_YEAR_KEY] + if year is not None: + ufo.info.year = year + note = master.userData[UFO_NOTE_KEY] + if note is not None: + ufo.info.note = note + + # All of this will go into the designspace as well + # "Native" designspace fonts will only have the designspace info + # FIXME: (jany) maybe we should not duplicate the information and only + # write it in the designspace? + widthValue = master.widthValue + weightValue = master.weightValue + if weightValue is not None: + ufo.lib[GLYPHS_PREFIX + 'weightValue'] = weightValue + if widthValue: + ufo.lib[GLYPHS_PREFIX + 'widthValue'] = widthValue + for number in ('', '1', '2', '3'): + custom_value = getattr(master, 'customValue' + number) + if custom_value: + ufo.lib[GLYPHS_PREFIX + 'customValue' + number] = custom_value + + self.to_ufo_blue_values(ufo, master) + self.to_ufo_guidelines(ufo, master) + self.to_ufo_master_user_data(ufo, master) + self.to_ufo_custom_params(ufo, master) + + master_id = master.id + if self.minimize_glyphs_diffs: + ufo.lib[MASTER_ID_LIB_KEY] = master_id + + +def to_glyphs_master_attributes(self, source, master): + ufo = source.font + try: + master.id = ufo.lib[MASTER_ID_LIB_KEY] + except KeyError: + # GSFontMaster has a random id by default + pass + + 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 + master.capHeight = ufo.info.capHeight + master.descender = ufo.info.descender + master.xHeight = ufo.info.xHeight + + horizontal_stems = ufo.info.postscriptStemSnapH + vertical_stems = ufo.info.postscriptStemSnapV + italic_angle = 0 + if ufo.info.italicAngle: + italic_angle = -ufo.info.italicAngle + if horizontal_stems: + master.horizontalStems = horizontal_stems + if vertical_stems: + master.verticalStems = vertical_stems + if italic_angle: + master.italicAngle = italic_angle + + if ufo.info.year is not None: + master.userData[UFO_YEAR_KEY] = ufo.info.year + if ufo.info.note is not None: + master.userData[UFO_NOTE_KEY] = ufo.info.note + + 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) diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index 18063d377..29ee33d65 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -17,29 +17,45 @@ from collections import deque +from .constants import GLYPHS_PREFIX + def to_ufo_names(self, ufo, master, family_name): width = master.width weight = master.weight custom = master.customName + + if weight: + ufo.lib[GLYPHS_PREFIX + 'weight'] = weight + if width: + ufo.lib[GLYPHS_PREFIX + 'width'] = width + if custom: + ufo.lib[GLYPHS_PREFIX + 'customName'] = 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 ) - styleMapFamilyName, styleMapStyleName = build_stylemap_names( - family_name=family_name, - style_name=styleName, - is_bold=(styleName == 'Bold'), - is_italic=is_italic - ) ufo.info.familyName = family_name ufo.info.styleName = styleName - ufo.info.styleMapFamilyName = styleMapFamilyName - ufo.info.styleMapStyleName = styleMapStyleName + + # FIXME: (jany) should be the responsibility of ufo2ft? + # Anyway, only generate the styleMap names if we're not round-tripping + # (i.e. generating UFOs for fontmake, the traditional use-case of + # glyphsLib.) + if not self.minimize_glyphs_diffs: + styleMapFamilyName, styleMapStyleName = build_stylemap_names( + family_name=family_name, + style_name=styleName, + is_bold=(styleName == 'Bold'), + is_italic=is_italic + ) + ufo.info.styleMapFamilyName = styleMapFamilyName + ufo.info.styleMapStyleName = styleMapStyleName def build_stylemap_names(family_name, style_name, is_bold=False, @@ -65,7 +81,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 @@ -96,3 +112,22 @@ def _get_linked_style(style_name, is_bold, is_italic): else: linked_style.appendleft(part) return ' '.join(linked_style) + + +def to_glyphs_family_names(self, ufo, merge=False): + if not merge: + # First UFO + self.font.familyName = ufo.info.familyName + else: + # Subsequent UFOs + if self.font.familyName != ufo.info.familyName: + raise RuntimeError('All UFOs should have the same family name.') + + +def to_glyphs_master_names(self, ufo, master): + name = ufo.info.styleName + weight = ufo.lib.get(GLYPHS_PREFIX + 'weight') + width = ufo.lib.get(GLYPHS_PREFIX + 'width') + custom = ufo.lib.get(GLYPHS_PREFIX + 'customName') + + master.set_all_name_components(name, weight, width, custom) diff --git a/Lib/glyphsLib/builder/paths.py b/Lib/glyphsLib/builder/paths.py index 37a697b31..9985fc6e5 100644 --- a/Lib/glyphsLib/builder/paths.py +++ b/Lib/glyphsLib/builder/paths.py @@ -15,14 +15,20 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +from glyphsLib import types +from glyphsLib import classes -def to_ufo_draw_paths(self, pen, paths): + +def to_ufo_paths(self, ufo_glyph, layer): """Draw .glyphs paths onto a pen.""" + pen = ufo_glyph.getPointPen() - for path in paths: - pen.beginPath() + for path in layer.paths: nodes = list(path.nodes) # the list is changed below, otherwise you can't draw more than once per session. + for node in nodes: + self.to_ufo_node_user_data(ufo_glyph, node) + pen.beginPath() if not nodes: pen.endPath() continue @@ -35,8 +41,39 @@ def to_ufo_draw_paths(self, pen, paths): # stored at the end of the nodes list. nodes.insert(0, nodes.pop()) for node in nodes: - node_type = node.type - if node_type not in ['line', 'curve', 'qcurve']: - node_type = None + node_type = _to_ufo_node_type(node.type) pen.addPoint(tuple(node.position), segmentType=node_type, smooth=node.smooth) pen.endPath() + + +def to_glyphs_paths(self, ufo_glyph, layer): + for contour in ufo_glyph: + path = self.glyphs_module.GSPath() + for point in contour: + node = self.glyphs_module.GSNode() + node.position = types.Point(point.x, point.y) + node.type = _to_glyphs_node_type(point.segmentType) + node.smooth = point.smooth + node.name = point.name + path.nodes.append(node) + path.closed = not contour.open + if not contour.open: + path.nodes.append(path.nodes.pop(0)) + layer.paths.append(path) + + for node in path.nodes: + self.to_glyphs_node_user_data(ufo_glyph, node) + + +def _to_ufo_node_type(node_type): + if node_type not in ['line', 'curve', 'qcurve']: + return None + return node_type + + +def _to_glyphs_node_type(node_type): + if node_type is None: + return classes.OFFCURVE + if node_type == 'move': + return classes.LINE + return node_type diff --git a/Lib/glyphsLib/builder/sources.py b/Lib/glyphsLib/builder/sources.py new file mode 100644 index 000000000..87e9e4ee8 --- /dev/null +++ b/Lib/glyphsLib/builder/sources.py @@ -0,0 +1,77 @@ +# 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) + + 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 f90659bf4..b011a457c 100644 --- a/Lib/glyphsLib/builder/user_data.py +++ b/Lib/glyphsLib/builder/user_data.py @@ -15,33 +15,142 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -from .constants import GLYPHS_PREFIX +import base64 +import os +import posixpath -MASTER_USER_DATA_KEY = GLYPHS_PREFIX + 'fontMaster.userData' +from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX, PUBLIC_PREFIX + +UFO_DATA_KEY = GLYPHLIB_PREFIX + 'ufoData' +FONT_USER_DATA_KEY = GLYPHLIB_PREFIX + 'fontUserData' +LAYER_LIB_KEY = GLYPHLIB_PREFIX + 'layerLib' +GLYPH_USER_DATA_KEY = GLYPHLIB_PREFIX + 'glyphUserData' +NODE_USER_DATA_KEY = GLYPHLIB_PREFIX + 'nodeUserData' + + +def to_designspace_family_user_data(self): + if self.use_designspace: + 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): """Set family-wide user data as Glyphs does.""" - user_data = self.font.userData - for key in user_data.keys(): - ufo.lib[key] = user_data[key] + if not self.use_designspace: + ufo.lib[FONT_USER_DATA_KEY] = dict(self.font.userData) def to_ufo_master_user_data(self, ufo, master): """Set master-specific user data as Glyphs does.""" - user_data = master.userData + for key in master.userData.keys(): + if _user_data_has_no_special_meaning(key): + ufo.lib[key] = master.userData[key] + + # Restore UFO data files + if UFO_DATA_KEY in master.userData: + for filename, data in master.userData[UFO_DATA_KEY].items(): + os_filename = os.path.join(*filename.split('/')) + ufo.data[os_filename] = base64.b64decode(data) + + +def to_ufo_glyph_user_data(self, ufo, glyph): + key = GLYPH_USER_DATA_KEY + '.' + glyph.name + if glyph.userData: + ufo.lib[key] = dict(glyph.userData) + + +def to_ufo_layer_lib(self, ufo_layer): + key = LAYER_LIB_KEY + '.' + ufo_layer.name + if key in self.font.userData.keys(): + ufo_layer.lib = self.font.userData[key] + + +def to_ufo_layer_user_data(self, ufo_glyph, layer): + user_data = layer.userData + for key in user_data.keys(): + if _user_data_has_no_special_meaning(key): + ufo_glyph.lib[key] = user_data[key] + + +def to_ufo_node_user_data(self, ufo_glyph, node): + user_data = node.userData if user_data: - data = {} - for key in user_data.keys(): - data[key] = user_data[key] - ufo.lib[MASTER_USER_DATA_KEY] = data + path_index, node_index = node._indices() + key = '{}.{}.{}'.format(NODE_USER_DATA_KEY, path_index, node_index) + ufo_glyph.lib[key] = dict(user_data) + +def to_glyphs_family_user_data_from_designspace(self): + """Set the GSFont userData from the designspace family-wide lib data.""" + target_user_data = self.font.userData + for key, value in self.designspace.lib.items(): + if _user_data_has_no_special_meaning(key): + target_user_data[key] = value -def to_glyphs_family_user_data(self, ufo): - """Set the GSFont userData from the UFO family-wide user data.""" - pass + +def to_glyphs_family_user_data_from_ufo(self, ufo): + """Set the GSFont userData from the UFO family-wide lib data.""" + target_user_data = self.font.userData + try: + for key, value in ufo.lib[FONT_USER_DATA_KEY].items(): + # Existing values taken from the designspace lib take precedence + if key not in target_user_data.keys(): + target_user_data[key] = value + except KeyError: + # No FONT_USER_DATA in ufo.lib + pass def to_glyphs_master_user_data(self, ufo, master): - """Set the GSFontMaster userData from the UFO master-specific user data.""" - pass + """Set the GSFontMaster userData from the UFO master-specific lib data.""" + target_user_data = master.userData + for key, value in ufo.lib.items(): + if _user_data_has_no_special_meaning(key): + target_user_data[key] = value + + # Save UFO data files + if ufo.data.fileNames: + ufo_data = {} + for os_filename in ufo.data.fileNames: + filename = posixpath.join(*os_filename.split(os.path.sep)) + data_bytes = base64.b64encode(ufo.data[os_filename]) + # FIXME: (jany) The `decode` is here because putting bytes in + # userData doesn't work in Python 3. (comes out as `"b'stuff'"`) + ufo_data[filename] = data_bytes.decode() + master.userData[UFO_DATA_KEY] = ufo_data + + +def to_glyphs_glyph_user_data(self, ufo, glyph): + key = GLYPH_USER_DATA_KEY + '.' + glyph.name + if key in ufo.lib: + glyph.userData = ufo.lib[key] + + +def to_glyphs_layer_lib(self, ufo_layer): + user_data = {} + for key, value in ufo_layer.lib.items(): + if _user_data_has_no_special_meaning(key): + user_data[key] = value + + if user_data: + key = LAYER_LIB_KEY + '.' + ufo_layer.name + self.font.userData[key] = user_data + + +def to_glyphs_layer_user_data(self, ufo_glyph, layer): + user_data = layer.userData + for key, value in ufo_glyph.lib.items(): + if _user_data_has_no_special_meaning(key): + user_data[key] = value + + +def to_glyphs_node_user_data(self, ufo_glyph, node): + path_index, node_index = node._indices() + key = '{}.{}.{}'.format(NODE_USER_DATA_KEY, path_index, node_index) + if key in ufo_glyph.lib: + node.userData = ufo_glyph.lib[key] + + +def _user_data_has_no_special_meaning(key): + return not (key.startswith(GLYPHS_PREFIX) or key.startswith(PUBLIC_PREFIX)) diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 133195ca1..b9f649602 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -18,6 +18,7 @@ from __future__ import print_function, division, unicode_literals import re +import os import math import inspect import traceback @@ -25,8 +26,8 @@ import logging import glyphsLib from glyphsLib.types import ( - transform, point, rect, size, glyphs_datetime, color, floatToString, - readIntlist, writeIntlist, baseType) + ValueType, Transform, Point, Rect, Size, parse_datetime, parse_color, + floatToString, readIntlist, writeIntlist, UnicodesList) from glyphsLib.parser import Parser from glyphsLib.writer import Writer, escape_string from collections import OrderedDict @@ -110,6 +111,12 @@ PLUS = 4 MINUS = 5 +# Directions: +LTR = 0 # Left To Right (e.g. Latin) +RTL = 1 # Right To Left (e.g. Arabic, Hebrew) +LTRTTB = 3 # Left To Right, Top To Bottom +RTLTTB = 2 # Right To Left, Top To Bottom + # Reverse lookup for __repr__ hintConstants = { -2: 'Tag', @@ -143,16 +150,48 @@ 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.") -def hint_target(line=None): +def parse_hint_target(line=None): if line is None: return None if line[0] == "{": - return point(line) + return Point(line) else: return line @@ -220,6 +259,8 @@ def __init__(self): if not hasattr(self, key): klass = self._classesForName[key] if inspect.isclass(klass) and issubclass(klass, GSBase): + # FIXME: (jany) Why? + # For GSLayer::backgroundImage, I was getting [] instead of None when no image value = [] elif key in self._defaultsForName: value = self._defaultsForName.get(key) @@ -237,6 +278,10 @@ def __repr__(self): def classForName(self, name): return self._classesForName.get(name, str) + def default_attr_value(self, attr_name): + """Return the default value of the given attribute, if any.""" + + # Note: # The dictionary API exposed by GS* classes is "private" in the sense that: # * it should only be used by the parser, so it should only @@ -271,7 +316,7 @@ def shouldWriteValueForKey(self, key): return default != value if klass in (int, float, bool) and value == 0: return False - if isinstance(value, baseType) and value.value is None: + if isinstance(value, ValueType) and value.value is None: return False return True @@ -360,16 +405,17 @@ def orderedLayers(self): glyphLayerIds = [ l.associatedMasterId for l in self._owner._layers.values() + if l.associatedMasterId == l.layerId ] masterIds = [m.id for m in self._owner.parent.masters] intersectedLayerIds = set(glyphLayerIds) & set(masterIds) orderedLayers = [ - self._owner._layers.get(m.id) + self._owner._layers[m.id] for m in self._owner.parent.masters if m.id in intersectedLayerIds ] orderedLayers += [ - self._owner._layers.get(l.layerId) + self._owner._layers[l.layerId] for l in self._owner._layers.values() if l.layerId not in intersectedLayerIds ] @@ -429,7 +475,8 @@ def values(self): def append(self, FontMaster): FontMaster.font = self._owner - FontMaster.id = str(uuid.uuid4()).upper() + if not FontMaster.id: + FontMaster.id = str(uuid.uuid4()).upper() self._owner._masters.append(FontMaster) # Cycle through all glyphs and append layer @@ -482,20 +529,8 @@ def __getitem__(self, key): return self._owner._glyphs[key] if isinstance(key, basestring): - # by glyph name - for glyph in self._owner._glyphs: - if glyph.name == key: - return glyph - # by string representation as u'ä' - if len(key) == 1: - for glyph in self._owner._glyphs: - if glyph.unicode == "%04X" % (ord(key)): - return glyph - # by unicode - else: - for glyph in self._owner._glyphs: - if glyph.unicode == key.upper(): - return glyph + return self._get_glyph_by_string(key) + return None def __setitem__(self, key, glyph): @@ -513,9 +548,28 @@ def __delitem__(self, key): def __contains__(self, item): if isString(item): - raise "not implemented" + return self._get_glyph_by_string(item) is not None return item in self._owner._glyphs + def _get_glyph_by_string(self, key): + # FIXME: (jany) looks inefficient + if isinstance(key, basestring): + # by glyph name + for glyph in self._owner._glyphs: + if glyph.name == key: + return glyph + # by string representation as u'ä' + if len(key) == 1: + for glyph in self._owner._glyphs: + if glyph.unicode == "%04X" % (ord(key)): + return glyph + # by unicode + else: + for glyph in self._owner._glyphs: + if glyph.unicode == key.upper(): + return glyph + return None + def values(self): return self._owner._glyphs @@ -582,6 +636,8 @@ def __delitem__(self, key): if klass.name == key: del self.values()[index] + # FIXME: (jany) def __contains__ + def append(self, item): self.values().append(item) item._parent = self._owner @@ -624,14 +680,17 @@ def __getitem__(self, key): def __setitem__(self, key, layer): if isinstance(key, int) and self._owner.parent: - OldLayer = self._owner._layers[key] + OldLayer = self._owner._layers.values()[key] if key < 0: key = self.__len__() + key layer.layerId = OldLayer.layerId layer.associatedMasterId = OldLayer.associatedMasterId self._owner._setupLayer(layer, OldLayer.layerId) self._owner._layers[key] = layer - # TODO: replace by ID + elif isinstance(key, basestring) and self._owner.parent: + # FIXME: (jany) more work to do? + layer.parent = self._owner + self._owner._layers[key] = layer else: raise KeyError @@ -661,7 +720,8 @@ def append(self, layer): assert layer is not None self._ensureMasterLayers() if not layer.associatedMasterId: - layer.associatedMasterId = self._owner.parent.masters[0].id + if self._owner.parent: + layer.associatedMasterId = self._owner.parent.masters[0].id if not layer.layerId: layer.layerId = str(uuid.uuid4()).upper() self._owner._setupLayer(layer, layer.layerId) @@ -699,6 +759,8 @@ def _ensureMasterLayers(self): if not self._owner.parent: return for master in self._owner.parent.masters: + # if (master.id not in self._owner._layers or + # self._owner._layers[master.id] is None): if self._owner.parent.masters[master.id] is None: newLayer = GSLayer() newLayer.associatedMasterId = master.id @@ -709,6 +771,7 @@ def _ensureMasterLayers(self): def plistArray(self): return list(self._owner._layers.values()) + class LayerAnchorsProxy(Proxy): def __getitem__(self, key): @@ -909,7 +972,7 @@ def __delitem__(self, key): def __contains__(self, item): if isString(item): - return self._owner.__getitem__(item) is not None + return self.__getitem__(item) is not None return item in self._owner._customParameters def __iter__(self): @@ -953,7 +1016,9 @@ class UserDataProxy(Proxy): def __getitem__(self, key): if self._owner._userData is None: - raise KeyError + return None + # This is not the normal `dict` behaviour, because this does not raise + # `KeyError` and instead just returns `None`. It matches Glyphs.app. return self._owner._userData.get(key) def __setitem__(self, key, value): @@ -974,6 +1039,8 @@ def __contains__(self, item): def __iter__(self): if self._owner._userData is None: return + # This is not the normal `dict` behaviour, because this yields values + # instead of keys. It matches Glyphs.app though. Urg. for value in self._owner._userData.values(): yield value @@ -1023,7 +1090,7 @@ class GSCustomParameter(GSBase): 'openTypeVheaVertTypoAscender', 'openTypeVheaVertTypoDescender', 'openTypeVheaVertTypoLineGap', 'postscriptBlueFuzz', 'postscriptBlueShift', - 'postscriptDefaultWidthX', 'postscriptSlantAngle', + 'postscriptDefaultWidthX', 'postscriptUnderlinePosition', 'postscriptUnderlineThickness', 'postscriptUniqueID', 'postscriptWindowsCharacterSet', 'shoulderHeight', @@ -1033,7 +1100,7 @@ class GSCustomParameter(GSBase): 'vheaVertDescender', 'vheaVertLineGap', 'weightClass', 'widthClass', 'winAscent', 'winDescent', 'year', 'Grid Spacing')) _CUSTOM_FLOAT_PARAMS = frozenset(( - 'postscriptBlueScale',)) + 'postscriptSlantAngle', 'postscriptBlueScale',)) _CUSTOM_BOOL_PARAMS = frozenset(( 'isFixedPitch', 'postscriptForceBold', 'postscriptIsFixedPitch', @@ -1086,14 +1153,14 @@ def setValue(self, value): class GSAlignmentZone(GSBase): - def __init__(self, pos=0, size=20): + super(GSAlignmentZone, self).__init__() self.position = pos self.size = size def read(self, src): if src is not None: - p = point(src) + p = Point(src) self.position = float(p.value[0]) self.size = float(p.value[1]) return self @@ -1115,14 +1182,14 @@ class GSGuideLine(GSBase): "alignment": str, "angle": float, "locked": bool, - "position": point, + "position": Point, "showMeasurement": bool, "filter": str, "name": unicode, } _parent = None _defaultsForName = { - "position": point(0, 0), + "position": Point(0, 0), } def __init__(self): @@ -1133,12 +1200,15 @@ def __repr__(self): (self.__class__.__name__, self.position.x, self.position.y, self.angle) - @property def parent(self): return self._parent +MASTER_NAME_WEIGHTS = ('Light', 'SemiLight', 'SemiBold', 'Bold') +MASTER_NAME_WIDTHS = ('Condensed', 'SemiCondensed', 'Extended', 'SemiExtended') + + class GSFontMaster(GSBase): _classesForName = { "alignmentZones": GSAlignmentZone, @@ -1146,11 +1216,8 @@ class GSFontMaster(GSBase): "capHeight": float, "custom": unicode, "customValue": float, - "custom1": unicode, "customValue1": float, - "custom2": unicode, "customValue2": float, - "custom3": unicode, "customValue3": float, "customParameters": GSCustomParameter, "descender": float, @@ -1170,18 +1237,26 @@ 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", "weightValue": 100.0, "widthValue": 100.0, + "customValue": 0.0, + "customValue1": 0.0, + "customValue2": 0.0, + "customValue3": 0.0, "xHeight": 500, "capHeight": 700, "ascender": 800, + "descender": -200, } _wrapperKeysTranslate = { "guideLines": "guides", "custom": "customName", - "custom1": "customName1", - "custom2": "customName2", - "custom3": "customName3", + "name": "_name", } _keyOrder = ( "alignmentZones", @@ -1189,11 +1264,8 @@ class GSFontMaster(GSBase): "capHeight", "custom", "customValue", - "custom1", "customValue1", - "custom2", "customValue2", - "custom3", "customValue3", "customParameters", "descender", @@ -1215,15 +1287,14 @@ class GSFontMaster(GSBase): def __init__(self): super(GSFontMaster, self).__init__() + self.id = str(uuid.uuid4()) self.font = None self._name = None self._customParameters = [] - self._weight = "Regular" - self._width = "Regular" self.italicAngle = 0.0 self._userData = None + self.customName = '' for number in ('', '1', '2', '3'): - setattr(self, 'customName' + number, '') setattr(self, 'customValue' + number, 0.0) def __repr__(self): @@ -1231,42 +1302,88 @@ 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 in ("xHeight", "capHeight", "ascender"): + if key in ("weight", "width"): + return getattr(self, key) != "Regular" + if key in ("xHeight", "capHeight", "ascender", "descender"): # Always write those values return True - if key == "name": - if getattr(self, key) == "Regular": - return False - return True + if key == "_name": + # Only write out the name if we can't make it by joining the parts + return self._name != self.name return super(GSFontMaster, self).shouldWriteValueForKey(key) @property def name(self): - 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): - self._name = value + def name(self, name): + """This function will take the given name and split it into components + weight, width, customName, and possibly the full name. + This is what Glyphs 1113 seems to be doing, approximately. + """ + weight, width, custom_name = self._splitName(name) + self.set_all_name_components(name, weight, width, custom_name) + + def set_all_name_components(self, name, weight, width, custom_name): + """This function ensures that after being called, the master.name, + master.weight, master.width, and master.customName match the given + values. + """ + self.weight = weight or 'Regular' + self.width = width or 'Regular' + self.customName = custom_name or '' + # Only store the requested name if we can't build it from the parts + if self._joinName() == name: + self._name = None + del self.customParameters['Master Name'] + else: + self._name = name + self.customParameters['Master Name'] = name + + def _joinName(self): + names = [self.weight, self.width, self.customName] + names = [n for n in names if n] # Remove None and empty string + # Remove all occurences of 'Regular' + while 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 = 'Regular' + custom = '' + names = [] + previous_was_removed = False + for name in value.split(" "): + if name == 'Regular': + pass + elif name in MASTER_NAME_WEIGHTS: + if previous_was_removed: + # Get the double space in custom + names.append('') + previous_was_removed = True + weight = name + elif name in MASTER_NAME_WIDTHS: + if previous_was_removed: + # Get the double space in custom + names.append('') + previous_was_removed = True + width = name + else: + previous_was_removed = False + names.append(name) + custom = " ".join(names).strip() + return (weight, width, custom) customParameters = property( lambda self: CustomParametersProxy(self), @@ -1276,26 +1393,6 @@ def name(self, value): lambda self: UserDataProxy(self), lambda self, value: UserDataProxy(self).setter(value)) - @property - def weight(self): - if self._weight is not None: - return self._weight - return "Regular" - - @weight.setter - def weight(self, value): - self._weight = value - - @property - def width(self): - if self._width is not None: - return self._width - return "Regular" - - @width.setter - def width(self, value): - self._width = value - class GSNode(GSBase): _PLIST_VALUE_RE = re.compile( @@ -1310,7 +1407,8 @@ class GSNode(GSBase): def __init__(self, position=(0, 0), nodetype=LINE, smooth=False, name=None): - self.position = point(position[0], position[1]) + super(GSNode, self).__init__() + self.position = Point(position[0], position[1]) self.type = nodetype self.smooth = smooth self._parent = None @@ -1349,7 +1447,7 @@ def plistValue(self): def read(self, line): m = self._PLIST_VALUE_RE.match(line).groups() - self.position = point(float(m[0]), float(m[1])) + self.position = Point(float(m[0]), float(m[1])) self.type = m[2].lower() self.smooth = bool(m[3]) @@ -1449,6 +1547,17 @@ def _decode_dict_as_string(self, value): """Reverse function of _encode_string_as_dict""" return self._ESCAPED_CHAR_RE.sub(self._unescape_char, value) + def _indices(self): + """Find the path_index and node_index that identify the given node.""" + path = self.parent + layer = path.parent + for path_index in range(len(layer.paths)): + if path == layer.paths[path_index]: + for node_index in range(len(path.nodes)): + if self == path.nodes[node_index]: + return Point(path_index, node_index) + return None + class GSPath(GSBase): _classesForName = { @@ -1461,7 +1570,7 @@ class GSPath(GSBase): _parent = None def __init__(self): - self._closed = True + super(GSPath, self).__init__() self.nodes = [] @property @@ -1546,7 +1655,7 @@ def bounds(self): top = newTop else: top = max(top, newTop) - return rect(point(left, bottom), point(right - left, top - bottom)) + return Rect(Point(left, bottom), Point(right - left, top - bottom)) @property def direction(self): @@ -1597,7 +1706,13 @@ def applyTransform(self, transformationMatrix): # Needs more attention. assert len(transformationMatrix) == 6 for node in self.nodes: - transformation = ( Affine.translation(transformationMatrix[4], transformationMatrix[5]) * Affine.scale(transformationMatrix[0], transformationMatrix[3]) * Affine.shear(transformationMatrix[2] * 45.0, transformationMatrix[1] * 45.0) ) + transformation = ( + Affine.translation(transformationMatrix[4], + transformationMatrix[5]) * + Affine.scale(transformationMatrix[0], + transformationMatrix[3]) * + Affine.shear(transformationMatrix[2] * 45.0, + transformationMatrix[1] * 45.0)) x, y = (node.position.x, node.position.y) * transformation node.position.x = x node.position.y = y @@ -1609,7 +1724,7 @@ def appendNode(self, node): if not hasattr(self, 'nodes'): # instead of defining this in __init__(), because I hate super() self.nodes = [] self.nodes.append(node) - self.append(point(node.position.x, node.position.y)) + self.append(Point(node.position.x, node.position.y)) @property def nextSegment(self): @@ -1705,13 +1820,13 @@ class GSComponent(GSBase): "locked": bool, "name": unicode, "piece": dict, - "transform": transform, + "transform": Transform, } _wrapperKeysTranslate = { "piece": "smartComponentValues", } _defaultsForName = { - "transform": transform(1, 0, 0, 1, 0, 0), + "transform": Transform(1, 0, 0, 1, 0, 0), } _parent = None @@ -1723,7 +1838,7 @@ def __init__(self, glyph="", offset=(0, 0), scale=(1, 1), transform=None): if scale != (1, 1) or offset != (0, 0): xx, yy = scale dx, dy = offset - self.transform = transform(xx, 0, 0, yy, dx, dy) + self.transform = Transform(xx, 0, 0, yy, dx, dy) else: self.transform = transform @@ -1732,14 +1847,13 @@ def __init__(self, glyph="", offset=(0, 0), scale=(1, 1), transform=None): elif isinstance(glyph, GSGlyph): self.name = glyph.name - def __repr__(self): return '' % \ (self.name, self.transform[4], self.transform[5]) def shouldWriteValueForKey(self, key): if key == "piece": - value = getattr(self, key) + value = self.smartComponentValues return len(value) > 0 return super(GSComponent, self).shouldWriteValueForKey(key) @@ -1750,7 +1864,7 @@ def parent(self): # .position @property def position(self): - return point(self.transform[4], self.transform[5]) + return Point(self.transform[4], self.transform[5]) @position.setter def position(self, value): self.transform[4] = value[0] @@ -1786,7 +1900,7 @@ def rotation(self, value): def updateAffineTransform(self): affine = list(Affine.translation(self.transform[4], self.transform[5]) * Affine.scale(self._sX, self._sY) * Affine.rotation(self._R))[:6] - self.transform = transform(affine[0], affine[1], affine[3], affine[4], affine[2], affine[5]) + self.transform = Transform(affine[0], affine[1], affine[3], affine[4], affine[2], affine[5]) @property def componentName(self): @@ -1824,11 +1938,11 @@ def bounds(self): right, top = self.applyTransformation(right, top) if left is not None and bottom is not None and right is not None and top is not None: - return rect(point(left, bottom), point(right - left, top - bottom)) + return Rect(Point(left, bottom), Point(right - left, top - bottom)) - smartComponentValues = property( - lambda self: self.piece, - lambda self, value: setattr(self, "piece", value)) + # smartComponentValues = property( + # lambda self: self.piece, + # lambda self, value: setattr(self, "piece", value)) class GSSmartComponentAxis(GSBase): @@ -1856,11 +1970,11 @@ def shouldWriteValueForKey(self, key): class GSAnchor(GSBase): _classesForName = { "name": unicode, - "position": point, + "position": Point, } _parent = None _defaultsForName = { - "position": point(0, 0), + "position": Point(0, 0), } def __init__(self, name=None, position=None): @@ -1875,6 +1989,11 @@ def __repr__(self): (self.__class__.__name__, self.name, self.position[0], self.position[1]) + def shouldWriteValueForKey(self, key): + if key == 'position': + return True + return super(GSAnchor, self).shouldWriteValueForKey(key) + @property def parent(self): return self._parent @@ -1884,22 +2003,26 @@ class GSHint(GSBase): _classesForName = { "horizontal": bool, "options": int, # bitfield - "origin": point, # Index path to node - "other1": point, # Index path to node for third node - "other2": point, # Index path to node for fourth node - "place": point, # (position, width) - "scale": point, # for corners + "origin": Point, # Index path to node + "other1": Point, # Index path to node for third node + "other2": Point, # Index path to node for fourth node + "place": Point, # (position, width) + "scale": Point, # for corners "stem": int, # index of stem - "target": hint_target, # Index path to node or 'up'/'down' + "target": parse_hint_target, # Index path to node or 'up'/'down' "type": str, "name": unicode, "settings": dict } - _defaultsForName = { + # TODO: (jany) check defaults in glyphs + "origin": None, + "other1": None, + "other2": None, + "place": None, + "scale": None, "stem": -2, } - _keyOrder = ( "horizontal", "origin", @@ -1916,12 +2039,6 @@ class GSHint(GSBase): ) def shouldWriteValueForKey(self, key): - if key == "stem": - if self.stem == -2: - return None - if (key in ['origin', 'other1', 'other2', 'place', 'scale'] and - getattr(self, key).value == getattr(self, key).default): - return None if key == "settings" and (self.settings is None or len(self.settings) == 0): return None return super(GSHint, self).shouldWriteValueForKey(key) @@ -1961,31 +2078,12 @@ def __repr__(self): def parent(self): return self._parent - def _find_node_by_indices(self, point): - """"Find the GSNode that is refered to by the given indices.""" - path_index, node_index = point - layer = self.parent - path = layer.paths[int(path_index)] - node = path.nodes[int(node_index)] - return node - - def _find_indices_for_node(self, node): - """Find the path_index and node_index that identify the given node.""" - path = node.parent - layer = path.parent - for path_index in range(len(layer.paths)): - if path == layer.paths[path_index]: - for node_index in range(len(path.nodes)): - if node == path.nodes[node_index]: - return point(path_index, node_index) - return None - @property def originNode(self): if self._originNode is not None: return self._originNode if self._origin is not None: - return self._find_node_by_indices(self._origin) + return self.parent._find_node_by_indices(self._origin) @originNode.setter def originNode(self, node): @@ -1997,7 +2095,7 @@ def origin(self): if self._origin is not None: return self._origin if self._originNode is not None: - return self._find_indices_for_node(self._originNode) + return self._originNode._indices() @origin.setter def origin(self, origin): @@ -2009,7 +2107,7 @@ def targetNode(self): if self._targetNode is not None: return self._targetNode if self._target is not None: - return self._find_node_by_indices(self._target) + return self.parent._find_node_by_indices(self._target) @targetNode.setter def targetNode(self, node): @@ -2021,7 +2119,7 @@ def target(self): if self._target is not None: return self._target if self._targetNode is not None: - return self._find_indices_for_node(self._targetNode) + return self._targetNode._indices() @target.setter def target(self, target): @@ -2033,7 +2131,7 @@ def otherNode1(self): if self._otherNode1 is not None: return self._otherNode1 if self._other1 is not None: - return self._find_node_by_indices(self._other1) + return self.parent._find_node_by_indices(self._other1) @otherNode1.setter def otherNode1(self, node): @@ -2045,7 +2143,7 @@ def other1(self): if self._other1 is not None: return self._other1 if self._otherNode1 is not None: - return self._find_indices_for_node(self._otherNode1) + return self._otherNode1._indices() @other1.setter def other1(self, other1): @@ -2057,7 +2155,7 @@ def otherNode2(self): if self._otherNode2 is not None: return self._otherNode2 if self._other2 is not None: - return self._find_node_by_indices(self._other2) + return self.parent._find_node_by_indices(self._other2) @otherNode2.setter def otherNode2(self, node): @@ -2069,7 +2167,7 @@ def other2(self): if self._other2 is not None: return self._other2 if self._otherNode2 is not None: - return self._find_indices_for_node(self._otherNode2) + return self._otherNode2._indices() @other2.setter def other2(self, other2): @@ -2128,11 +2226,18 @@ class GSFeaturePrefix(GSFeature): class GSAnnotation(GSBase): _classesForName = { "angle": float, - "position": point, + "position": Point, "text": unicode, "type": str, "width": float, # the width of the text field or size of the cicle } + _defaultsForName = { + "angle": 0.0, + "position": Point(), + "text": None, + "type": 0, + "width": 100.0, + } _parent = None @property @@ -2143,34 +2248,44 @@ def parent(self): class GSInstance(GSBase): _classesForName = { "customParameters": GSCustomParameter, + "active": bool, "exports": bool, "instanceInterpolations": dict, "interpolationCustom": float, "interpolationCustom1": float, "interpolationCustom2": float, + "interpolationCustom3": float, "interpolationWeight": float, "interpolationWidth": float, "isBold": bool, "isItalic": bool, - "linkStyle": str, + "linkStyle": unicode, "manualInterpolation": bool, "name": unicode, - "weightClass": str, - "widthClass": str, + "weightClass": unicode, + "widthClass": unicode, } _defaultsForName = { + "active": True, "exports": True, - "interpolationWeight": 100, - "interpolationWidth": 100, + "interpolationCustom": 0.0, + "interpolationCustom1": 0.0, + "interpolationCustom2": 0.0, + "interpolationCustom3": 0.0, + "interpolationWeight": 100.0, + "interpolationWidth": 100.0, "weightClass": "Regular", "widthClass": "Medium (normal)", + "instanceInterpolations": {}, } _keyOrder = ( + "active", "exports", "customParameters", "interpolationCustom", "interpolationCustom1", "interpolationCustom2", + "interpolationCustom3", "interpolationWeight", "interpolationWidth", "instanceInterpolations", @@ -2182,42 +2297,44 @@ class GSInstance(GSBase): "weightClass", "widthClass", ) + _wrapperKeysTranslate = { + "weightClass": "weight", + "widthClass": "width", + "interpolationWeight": "weightValue", + "interpolationWidth": "widthValue", + "interpolationCustom": "customValue", + "interpolationCustom1": "customValue1", + "interpolationCustom2": "customValue2", + "interpolationCustom3": "customValue3", + } def interpolateFont(): pass def __init__(self): - self.exports = True + super(GSInstance, self).__init__() + # TODO: (jany) review this and move as much as possible into + # "_defaultsForKey" self.name = "Regular" - self.weight = "Regular" - self.width = "Regular" self.custom = None self.linkStyle = "" - self.interpolationWeight = 100.0 - self.interpolationWidth = 100.0 - self.interpolationCustom = 0.0 self.visible = True self.isBold = False self.isItalic = False - self.widthClass = "Medium (normal)" - self.weightClass = "Regular" self._customParameters = [] customParameters = property( lambda self: CustomParametersProxy(self), lambda self, value: CustomParametersProxy(self).setter(value)) - weightValue = property( - lambda self: self.interpolationWeight, - lambda self, value: setattr(self, "interpolationWeight", value)) - - widthValue = property( - lambda self: self.interpolationWidth, - lambda self, value: setattr(self, "interpolationWidth", value)) + @property + def exports(self): + """Deprecated alias for `active`, which is in the documentation.""" + return self.active - customValue = property( - lambda self: self.interpolationCustom, - lambda self, value: setattr(self, "interpolationCustom", value)) + @exports.setter + def exports(self, value): + self.active = value @property def familyName(self): @@ -2228,7 +2345,7 @@ def familyName(self): @familyName.setter def familyName(self, value): - self.customParameters["famiyName"] = value + self.customParameters["familyName"] = value @property def preferredFamily(self): @@ -2308,14 +2425,18 @@ def fullName(self, value): class GSBackgroundImage(GSBase): _classesForName = { - "crop": rect, + "crop": Rect, "imagePath": unicode, "locked": bool, - "transform": transform, + "transform": Transform, "alpha": int, } _defaultsForName = { - "transform": transform(1, 0, 0, 1, 0, 0), + "alpha": 50, + "transform": Transform(1, 0, 0, 1, 0, 0), + } + _wrapperKeysTranslate = { + "alpha": "_alpha", } def __init__(self, path=None): @@ -2333,15 +2454,18 @@ def path(self): @path.setter def path(self, value): # FIXME: (jany) use posix pathnames here? - if os.dirname(os.abspath(value)) == os.dirname(os.abspath(self.parent.parent.parent.filepath)): - self.imagePath = os.path.basename(value) - else: - self.imagePath = value + # FIXME: (jany) the following code must have never been tested. + # Also it would require to keep track of the parent for background + # images. + # if os.path.dirname(os.path.abspath(value)) == os.path.dirname(os.path.abspath(self.parent.parent.parent.filepath)): + # self.imagePath = os.path.basename(value) + # else: + self.imagePath = value # .position @property def position(self): - return point(self.transform[4], self.transform[5]) + return Point(self.transform[4], self.transform[5]) @position.setter def position(self, value): self.transform[4] = value[0] @@ -2371,26 +2495,20 @@ def rotation(self, value): self._R = value self.updateAffineTransform() - def updateAffineTransform(self): - affine = list(Affine.translation(self.transform[4], self.transform[5]) * Affine.scale(self._sX, self._sY) * Affine.rotation(self._R))[:6] - self.transform = [affine[0], affine[1], affine[3], affine[4], affine[2], affine[5]] + # .alpha + @property + def alpha(self): + return self._alpha + @alpha.setter + def alpha(self, value): + if not 10 <= value <= 100: + value = 50 + self._alpha = value -# FIXME: (jany) This class is not mentioned in the official docs? -class GSBackgroundLayer(GSBase): - _classesForName = { - "anchors": GSAnchor, - "annotations": GSAnnotation, - "backgroundImage": GSBackgroundImage, - "components": GSComponent, - "guideLines": GSGuideLine, - "hints": GSHint, - "paths": GSPath, - "visible": bool, - } - _wrapperKeysTranslate = { - "guideLines": "guides", - } + def updateAffineTransform(self): + affine = list(Affine.translation(self.transform[4], self.transform[5]) * Affine.scale(self._sX, self._sY) * Affine.rotation(self._R))[:6] + self.transform = Transform(affine[0], affine[1], affine[3], affine[4], affine[2], affine[5]) class GSLayer(GSBase): @@ -2398,9 +2516,10 @@ class GSLayer(GSBase): "anchors": GSAnchor, "annotations": GSAnnotation, "associatedMasterId": str, - "background": GSBackgroundLayer, + # The next line is added after we define GSBackgroundLayer + # "background": GSBackgroundLayer, "backgroundImage": GSBackgroundImage, - "color": color, + "color": parse_color, "components": GSComponent, "guideLines": GSGuideLine, "hints": GSHint, @@ -2417,13 +2536,14 @@ class GSLayer(GSBase): "widthMetricsKey": unicode, } _defaultsForName = { - "weight": 600, + "width": 600.0, "leftMetricsKey": None, "rightMetricsKey": None, "widthMetricsKey": None, } _wrapperKeysTranslate = { "guideLines": "guides", + "background": "_background", } _keyOrder = ( "anchors", @@ -2450,6 +2570,7 @@ class GSLayer(GSBase): def __init__(self): super(GSLayer, self).__init__() + self.parent = None self._anchors = [] self._hints = [] self._annotations = [] @@ -2458,6 +2579,8 @@ def __init__(self): self._paths = [] self._selection = [] self._userData = None + self._background = None + self.backgroundImage = None def __repr__(self): name = self.name @@ -2477,6 +2600,29 @@ def __lt__(self, other): if self.master and other.master and self.associatedMasterId == self.layerId: return self.master.weightValue < other.master.weightValue or self.master.widthValue < other.master.widthValue + @property + def layerId(self): + return self._layerId + + @layerId.setter + def layerId(self, value): + self._layerId = value + # Update the layer map in the parent glyph, if any. + # The "hasattr" is here because this setter is called by the GSBase + # __init__() method before the parent property is set. + if hasattr(self, 'parent') and self.parent: + parent_layers = OrderedDict() + updated = False + for id, layer in self.parent._layers.items(): + if layer == self: + parent_layers[self._layerId] = self + updated = True + else: + parent_layers[id] = layer + if not updated: + parent_layers[self._layerId] = self + self.parent._layers = parent_layers + @property def master(self): if self.associatedMasterId and self.parent: @@ -2484,13 +2630,13 @@ def master(self): return master def shouldWriteValueForKey(self, key): + if key == "width": + return True if key == "associatedMasterId": return self.layerId != self.associatedMasterId if key == "name": return (self.name is not None and len(self.name) > 0 and self.layerId != self.associatedMasterId) - if key in ("width"): - return True return super(GSLayer, self).shouldWriteValueForKey(key) @property @@ -2572,7 +2718,66 @@ def bounds(self): top = max(top, newTop) if left is not None and bottom is not None and right is not None and top is not None: - return rect(point(left, bottom), point(right - left, top - bottom)) + return Rect(Point(left, bottom), Point(right - left, top - bottom)) + + def _find_node_by_indices(self, point): + """"Find the GSNode that is refered to by the given indices. + + See GSNode::_indices() + """ + path_index, node_index = point + path = self.paths[int(path_index)] + node = path.nodes[int(node_index)] + return node + + @property + def background(self): + """Only a getter on purpose. See the tests.""" + if self._background is None: + self._background = GSBackgroundLayer() + self._background._foreground = self + return self._background + + # FIXME: (jany) how to check whether there is a background without calling + # ::background? + @property + def hasBackground(self): + return bool(self._background) + + @property + def foreground(self): + """Forbidden, and also forbidden to set it.""" + raise AttributeError + + +class GSBackgroundLayer(GSLayer): + def shouldWriteValueForKey(self, key): + if key == 'width': + return False + return super(GSBackgroundLayer, self).shouldWriteValueForKey(key) + + @property + def background(self): + return None + + @property + def foreground(self): + return self._foreground + + # The width property of this class behaves like this in Glyphs: + # - Always returns 600.0 + # - Settable but does not remember the value (basically useless) + # Reproduce this behaviour here so that the roundtrip does not rely on it. + @property + def width(self): + return 600.0 + + @width.setter + def width(self, whatever): + pass + + +GSLayer._classesForName['background'] = GSBackgroundLayer class GSGlyph(GSBase): @@ -2580,10 +2785,10 @@ class GSGlyph(GSBase): "bottomKerningGroup": str, "bottomMetricsKey": str, "category": str, - "color": color, + "color": parse_color, "export": bool, "glyphname": unicode, - "lastChange": glyphs_datetime, + "lastChange": parse_datetime, "layers": GSLayer, "leftKerningGroup": unicode, "leftKerningKey": unicode, @@ -2598,12 +2803,13 @@ class GSGlyph(GSBase): "subCategory": str, "topKerningGroup": str, "topMetricsKey": str, - "unicode": unicode, + "unicode": UnicodesList, "userData": dict, "vertWidthMetricsKey": str, "widthMetricsKey": unicode, } _wrapperKeysTranslate = { + "unicode": "unicodes", "glyphname": "name", "partsSettings": "smartComponentAxes", } @@ -2622,7 +2828,6 @@ class GSGlyph(GSBase): "subCategory": None, "userData": None, "widthMetricsKey": None, - "unicode": None, } _keyOrder = ( "color", @@ -2672,7 +2877,7 @@ def shouldWriteValueForKey(self, key): lambda self, value: GlyphLayerProxy(self).setter(value)) def _setupLayer(self, layer, key): - assert type(key) == str + assert isinstance(key, (str, unicode)) layer.parent = self layer.layerId = key # TODO use proxy `self.parent.masters[key]` @@ -2714,6 +2919,24 @@ def id(self): """An unique identifier for each glyph""" return self.name + @property + def unicode(self): + if self._unicodes: + return self._unicodes[0] + return None + + @unicode.setter + def unicode(self, unicode): + self._unicodes = UnicodesList(unicode) + + @property + def unicodes(self): + return self._unicodes + + @unicodes.setter + def unicodes(self, unicodes): + self._unicodes = UnicodesList(unicodes) + class GSFont(GSBase): _classesForName = { @@ -2722,7 +2945,7 @@ class GSFont(GSBase): "classes": GSClass, "copyright": unicode, "customParameters": GSCustomParameter, - "date": glyphs_datetime, + "date": parse_datetime, "designer": unicode, "designerURL": unicode, "disablesAutomaticAlignment": bool, @@ -2796,7 +3019,7 @@ def __repr__(self): return "<%s \"%s\">" % (self.__class__.__name__, self.familyName) def shouldWriteValueForKey(self, key): - if key in ("unitsPerEm", "versionMinor"): + if key in ("unitsPerEm", "versionMajor", "versionMinor"): return True return super(GSFont, self).shouldWriteValueForKey(key) @@ -2838,6 +3061,8 @@ def features(self): @features.setter def features(self, value): + # FIXME: (jany) why not use Proxy like every other attribute? + # FIXME: (jany) do the same for featurePrefixes? self._features = value for g in self._features: g._parent = self @@ -2851,6 +3076,7 @@ def masterForId(self, key): return master return None + # FIXME: (jany) Why is this not a FontInstanceProxy? @property def instances(self): return self._instances @@ -2907,3 +3133,42 @@ def gridLength(self): return self.grid / self.gridSubDivisions else: return self.grid + + EMPTY_KERNING_VALUE = (1 << 63) - 1 # As per the documentation + + def kerningForPair(self, fontMasterId, leftKey, rightKey, direction=LTR): + # TODO: (jany) understand and use the direction parameter + if not self._kerning: + return self.EMPTY_KERNING_VALUE + try: + return self._kerning[fontMasterId][leftKey][rightKey] + except KeyError: + return self.EMPTY_KERNING_VALUE + + def setKerningForPair(self, fontMasterId, leftKey, rightKey, value, + direction=LTR): + # TODO: (jany) understand and use the direction parameter + if not self._kerning: + self._kerning = {} + if fontMasterId not in self._kerning: + self._kerning[fontMasterId] = {} + if leftKey not in self._kerning[fontMasterId]: + self._kerning[fontMasterId][leftKey] = {} + self._kerning[fontMasterId][leftKey][rightKey] = value + + def removeKerningForPair(self, fontMasterId, leftKey, rightKey, + direction=LTR): + # TODO: (jany) understand and use the direction parameter + if not self._kerning: + return + if fontMasterId not in self._kerning: + return + if leftKey not in self._kerning[fontMasterId]: + return + if rightKey not in self._kerning[fontMasterId][leftKey]: + return + del(self._kerning[fontMasterId][leftKey][rightKey]) + if not self._kerning[fontMasterId][leftKey]: + del(self._kerning[fontMasterId][leftKey]) + if not self._kerning[fontMasterId]: + del(self._kerning[fontMasterId]) diff --git a/Lib/glyphsLib/glyphdata.py b/Lib/glyphsLib/glyphdata.py index e598b3667..371ee2b09 100644 --- a/Lib/glyphsLib/glyphdata.py +++ b/Lib/glyphsLib/glyphdata.py @@ -27,6 +27,7 @@ NARROW_PYTHON_BUILD = sys.maxunicode < 0x10FFFF +# FIXME: (jany) Shouldn't this be the class GSGlyphInfo? Glyph = namedtuple("Glyph", "name,production_name,unicode,category,subCategory") diff --git a/Lib/glyphsLib/interpolation.py b/Lib/glyphsLib/interpolation.py index cf9b53759..2f4149728 100644 --- a/Lib/glyphsLib/interpolation.py +++ b/Lib/glyphsLib/interpolation.py @@ -20,9 +20,11 @@ import os import xml.etree.ElementTree as etree -from glyphsLib.builder.custom_params import set_custom_params +from glyphsLib.builder.builders import UFOBuilder +from glyphsLib.builder.custom_params import to_ufo_custom_params from glyphsLib.builder.names import build_stylemap_names from glyphsLib.builder.constants import GLYPHS_PREFIX +from glyphsLib.builder.instances import apply_instance_data, InstanceData from glyphsLib.util import build_ufo_path, write_ufo, clean_ufo @@ -32,51 +34,13 @@ logger = logging.getLogger(__name__) -# 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. -DEFAULT_LOCS = { - 'weight': 100, - 'width': 100, - 'custom': 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, -} - +# DEPRECATED def interpolate(ufos, master_dir, out_dir, instance_data, round_geometry=True): """Create MutatorMath designspace and generate instances. Returns instance UFOs. """ + # Problem with this function: should take a designspace explicitly. from mutatorMath.ufo import build designspace_path, instance_files = build_designspace( @@ -92,6 +56,7 @@ def interpolate(ufos, master_dir, out_dir, instance_data, round_geometry=True): return instance_ufos +# DEPRECATED def build_designspace(masters, master_dir, out_dir, instance_data): """Just create MutatorMath designspace without generating instances. @@ -99,285 +64,21 @@ def build_designspace(masters, master_dir, out_dir, instance_data): (instance_path, instance_data) tuples which map instance UFO filenames to Glyphs data for that instance. """ - from mutatorMath.ufo.document import DesignSpaceDocumentWriter - - base_family = masters[0].info.familyName - assert all(m.info.familyName == base_family for m in masters), \ - 'Masters must all have same family' + # TODO: (jany) check whether this function is still useful + # No need to build a designspace, we should have it in "instance_data" + designspace = instance_data['designspace'] + # Move masters and instances to the designated directories for font in masters: write_ufo(font, master_dir) + for source in designspace.sources: + if source.font is font: + source.path = font.path + for instance in designspace.instances: + instance.path = os.path.join(out_dir, + os.path.basename(instance.filename)) - # 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', []))) - regular = find_regular_master( - masters=masters, - regularName=instance_data.get('Variation Font Origin')) - axes = get_axes(masters, regular, instances) - write_axes(axes, writer) - add_masters_to_writer(masters, regular, axes, writer) - instance_files = add_instances_to_writer( - writer, base_family, axes, instances, out_dir) - - # append base style shared by all masters to designspace file name - base_style = find_base_style(masters) - if base_style: - base_style = "-" + base_style - ds_name = (base_family + base_style).replace(' ', '') + '.designspace' - writer.path = os.path.join(master_dir, ds_name) - writer.save() - return writer.path, instance_files - - -# TODO: Use AxisDescriptor from fonttools once designSpaceDocument has been -# made part of fonttools. https://github.com/fonttools/fonttools/issues/911 -# https://github.com/LettError/designSpaceDocument#axisdescriptor-object -AxisDescriptor = namedtuple('AxisDescriptor', [ - 'minimum', 'maximum', 'default', 'name', 'tag', 'labelNames', 'map']) - - -def get_axes(masters, regular_master, instances): - # 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. - axes = OrderedDict() - for name, tag, userLocParam, defaultUserLoc in ( - ('weight', 'wght', 'weightClass', 400), - ('width', 'wdth', 'widthClass', 100), - ('custom', 'XXXX', None, 0)): - key = GLYPHS_PREFIX + name + 'Value' - interpolLocKey = 'interpolation' + name.title() - if any(key in master.lib for master in masters): - regularInterpolLoc = regular_master.lib.get(key, DEFAULT_LOCS[name]) - regularUserLoc = defaultUserLoc - labelNames = {"en": name.title()} - mapping = [] - for instance in instances: - interpolLoc = getattr(instance, interpolLocKey, - DEFAULT_LOCS[name]) - userLoc = interpolLoc - for param in instance.customParameters: - if param.name == userLocParam: - userLoc = float(getattr(param, 'value', - DEFAULT_LOCS[name])) - break - mapping.append((userLoc, interpolLoc)) - if interpolLoc == regularInterpolLoc: - regularUserLoc = userLoc - mapping = sorted(set(mapping)) # avoid duplicates - if mapping: - minimum = min([userLoc for userLoc, _ in mapping]) - maximum = max([userLoc for userLoc, _ in mapping]) - default = min(maximum, max(minimum, regularUserLoc)) # clamp - else: - minimum = maximum = default = defaultUserLoc - axes[name] = AxisDescriptor( - minimum=minimum, maximum=maximum, default=default, - name=name, tag=tag, labelNames=labelNames, map=mapping) - return axes - - -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 write_axes(axes, writer): - # TODO: MutatorMath's DesignSpaceDocumentWriter does not support - # axis label names. Once DesignSpaceDocument has been made part - # of fonttools, we can write them out in a less hacky way than here. - # The current implementation is rather terrible, but it works; - # extending the writer isn't worth the effort because we'll move away - # from it as soon as DesignSpaceDocument has landed in fonttools. - # https://github.com/fonttools/fonttools/issues/911 - for axis in axes.values(): - writer.addAxis(tag=axis.tag, name=axis.name, - minimum=axis.minimum, maximum=axis.maximum, - default=axis.default, warpMap=axis.map) - axisElement = writer.root.findall('.axes/axis')[-1] - for lang, name in sorted(axis.labelNames.items()): - labelname = etree.Element('labelname') - labelname.attrib['xml:lang'], labelname.text = lang, name - axisElement.append(labelname) - - -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 - - -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 add_masters_to_writer(ufos, regular, axes, writer): - """Add master UFOs to a MutatorMath document writer. - """ - for font in ufos: - family, style = font.info.familyName, font.info.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 - location = OrderedDict() - for axis in axes: - location[axis] = font.lib.get( - GLYPHS_PREFIX + axis + 'Value', DEFAULT_LOCS[axis]) - is_regular = (font is regular) - writer.addSource( - path=font.path, name='%s %s' % (family, style), - familyName=family, styleName=style, location=location, - copyFeatures=is_regular, copyGroups=is_regular, copyInfo=is_regular, - copyLib=is_regular) - - -def add_instances_to_writer(writer, family_name, axes, instances, out_dir): - """Add instances from Glyphs data to a MutatorMath document writer. - - Returns a list of pairs, corresponding to the - instances which will be output by the document writer. The font data is the - Glyphs data for this instance as a dict. - """ - ofiles = [] - for instance in instances: - familyName, postScriptFontName, ufo_path = None, None, None - for p in instance.customParameters: - param, value = p.name, p.value - if param == 'familyName': - familyName = value - elif param == 'postscriptFontName': - # Glyphs uses "postscriptFontName", not "postScriptFontName" - postScriptFontName = value - elif param == 'fileName': - ufo_path = os.path.join(out_dir, value + '.ufo') - if familyName is None: - familyName = family_name - - styleName = instance.name - if not ufo_path: - ufo_path = build_ufo_path(out_dir, familyName, 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 - location = OrderedDict() - for axis in axes: - location[axis] = getattr( - instance, 'interpolation' + axis.title(), DEFAULT_LOCS[axis]) - styleMapFamilyName, styleMapStyleName = build_stylemap_names( - family_name=familyName, - style_name=styleName, - is_bold=instance.isBold, - is_italic=instance.isItalic, - linked_style=instance.linkStyle, - ) - writer.startInstance( - name=' '.join((familyName, styleName)), - location=location, - familyName=familyName, - styleName=styleName, - postScriptFontName=postScriptFontName, - styleMapFamilyName=styleMapFamilyName, - styleMapStyleName=styleMapStyleName, - fileName=ufo_path) - - writer.writeInfo() - writer.writeKerning() - writer.endInstance() - - return ofiles - - -def _set_class_from_instance(ufo, data, key, codes): - class_name = getattr(data, key) - if class_name: - ufo.lib[GLYPHS_PREFIX + key] = class_name - if class_name in codes: - class_code = codes[class_name] - ufo_key = "".join(['openTypeOS2', key[0].upper(), key[1:]]) - setattr(ufo.info, ufo_key, class_code) + designspace_path = os.path.join(master_dir, designspace.filename) + designspace.write(designspace_path) - -def set_weight_class(ufo, instance_data): - """ Store `weightClass` instance attributes in the UFO lib, and set the - ufo.info.openTypeOS2WeightClass accordingly. - """ - _set_class_from_instance(ufo, instance_data, "weightClass", WEIGHT_CODES) - - -def set_width_class(ufo, instance_data): - """ Store `widthClass` instance attributes in the UFO lib, and set the - ufo.info.openTypeOS2WidthClass accordingly. - """ - _set_class_from_instance(ufo, instance_data, "widthClass", WIDTH_CODES) - - -def apply_instance_data(instance_data): - """Open instances, apply data, and re-save. - - Args: - instance_data: List of (path, data) tuples, one for each instance. - Returns: - List of opened and updated instance UFOs. - """ - from defcon import Font - - instance_ufos = [] - for path, data in instance_data: - ufo = Font(path) - set_weight_class(ufo, data) - set_width_class(ufo, data) - set_custom_params(ufo, data=data) - ufo.save() - instance_ufos.append(ufo) - return instance_ufos + return designspace_path, InstanceData(designspace) diff --git a/Lib/glyphsLib/parser.py b/Lib/glyphsLib/parser.py index 39c00de7f..0b829b5c9 100644 --- a/Lib/glyphsLib/parser.py +++ b/Lib/glyphsLib/parser.py @@ -12,7 +12,6 @@ # 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 fontTools.misc.py23 import tounicode, unichr, unicode @@ -22,6 +21,7 @@ import re import logging import sys +import base64 import glyphsLib @@ -32,6 +32,7 @@ class Parser(object): """Parses Python dictionaries from Glyphs source files.""" value_re = r'(".*?(?', re.DOTALL) def __init__(self, current_type=OrderedDict): self.current_type = current_type @@ -85,7 +87,7 @@ def _guess_current_type(self, parsed, value): current_type = unicode return current_type - def _parse(self, text, i): + def _parse(self, text, i, _parsing_unicodes=False): """Recursive function to parse a single dictionary, list, or value.""" m = self.start_dict_re.match(text, i) @@ -100,6 +102,14 @@ def _parse(self, text, i): i += len(parsed) return self._parse_list(text, i) + if _parsing_unicodes: + m = self.unicode_list_re.match(text, i) + if m: + parsed = m.group(0) + i += len(parsed) + unicode_list = m.group(1).split(",") + return unicode_list, i + m = self.value_re.match(text, i) if m: parsed, value = m.group(0), self._trim_value(m.group(1)) @@ -111,8 +121,8 @@ def _parse(self, text, i): value = reader.read(m.group(1)) return value, i - if (self.current_type is None or - self.current_type in (dict, OrderedDict)): + if (self.current_type is None + or self.current_type in (dict, OrderedDict)): self.current_type = self._guess_current_type(parsed, value) if self.current_type == bool: @@ -123,6 +133,13 @@ def _parse(self, text, i): return value, i + m = self.bytes_re.match(text, i) + if m: + parsed, value = m.group(0), m.group(1) + decoded = base64.b64decode(value) + i += len(parsed) + return decoded, i + else: self._fail('Unexpected content', text, i) @@ -151,7 +168,12 @@ def _parse_dict_into_object(self, res, text, i): if hasattr(res, "classForName"): self.current_type = res.classForName(name) i += len(parsed) - result = self._parse(text, i) + + if name == "unicode": + result = self._parse(text, i, _parsing_unicodes=True) + else: + result = self._parse(text, i) + try: res[name], i = result except: @@ -246,5 +268,6 @@ def main(args=None): for arg in args: glyphsLib.dump(load(open(arg, 'r', encoding='utf-8')), sys.stdout) + if __name__ == '__main__': main(sys.argv[1:]) diff --git a/Lib/glyphsLib/types.py b/Lib/glyphsLib/types.py index bb612f6fa..f6ed26954 100644 --- a/Lib/glyphsLib/types.py +++ b/Lib/glyphsLib/types.py @@ -20,26 +20,30 @@ import datetime import traceback import math +import copy from fontTools.misc.py23 import unicode __all__ = [ - 'transform', 'point', 'rect' + 'Transform', 'Point', 'Rect' ] -class baseType(object): +class ValueType(object): + """A base class for value types that are comparable in the Python sense + and readable/writable using the glyphsLib parser/writer. + """ default = None def __init__(self, value=None): if value: - self.value = self.read(value) + self.value = self.fromString(value) else: - self.value = self.default + self.value = copy.deepcopy(self.default) def __repr__(self): return "<%s %s>" % (self.__class__.__name__, self.plistValue()) - def read(self, src): + def fromString(self, src): """Return a typed value representing the structured glyphs strings.""" raise NotImplementedError('%s read' % type(self).__name__) @@ -47,56 +51,81 @@ def plistValue(self): """Return structured glyphs strings representing the typed value.""" raise NotImplementedError('%s write' % type(self).__name__) + # https://stackoverflow.com/questions/390250/elegant-ways-to-support-equivalence-equality-in-python-classes + def __eq__(self, other): + """Overrides the default implementation""" + if isinstance(self, other.__class__): + return self.value == other.value + return NotImplemented + + def __ne__(self, other): + """Overrides the default implementation (unnecessary in Python 3)""" + x = self == other + if x is not NotImplemented: + return not x + return NotImplemented + + def __hash__(self): + """Overrides the default implementation""" + return hash(self.value) + + +# class Vector +def Vector(dim): + class Vector(ValueType): + """Base type for number vectors (points, rects, transform matrices).""" + dimension = dim + default = [0.0] * dimension + regex = re.compile('{%s}' % ', '.join(['([-.e\\d]+)'] * dimension)) + + def fromString(self, src): + if isinstance(src, list): + assert len(src) == self.dimension + return src + src = src.replace('"', '') + return [float(i) for i in self.regex.match(src).groups()] + + def plistValue(self): + assert (isinstance(self.value, list) + and len(self.value) == self.dimension) + return '"{%s}"' % (', '.join(floatToString(v, 3) for v in self.value)) + + def __getitem__(self, key): + assert (isinstance(self.value, list) and + len(self.value) == self.dimension) + return self.value[key] + + def __setitem__(self, key, value): + assert (isinstance(self.value, list) and + len(self.value) == self.dimension) + self.value[key] = value + + def __len__(self): + return self.dimension + + return Vector + -class point(object): +class Point(Vector(2)): """Read/write a vector in curly braces.""" - dimension = 2 - default = [None, None] - regex = re.compile('{%s}' % ', '.join(['([-.e\\d]+)'] * dimension)) def __init__(self, value=None, value2=None, rect=None): if value is not None and value2 is not None: - self.value = [value, value2] - elif value is not None and value2 is None: - value = value.replace('"', '') - self.value = [float(i) for i in self.regex.match(value).groups()] - else: - self.value = self.default + value = [value, value2] + assert (value is None or + isinstance(value, (str, unicode)) or + isinstance(value, (list, tuple))) + super(Point, self).__init__(value) self.rect = rect def __repr__(self): return '' % (self.value[0], self.value[1]) - def plistValue(self): - assert (isinstance(self.value, list) and - len(self.value) == self.dimension) - if self.value is not self.default: - return '"{%s}"' % (', '.join(floatToString(v, 3) for v in self.value)) - - def __getitem__(self, key): - if type(key) is int and key < self.dimension: - if key < len(self.value): - return self.value[key] - else: - return 0 - else: - raise IndexError - - def __setitem__(self, key, value): - if type(key) is int and key < self.dimension: - while self.dimension > len(self.value): - self.value.append(0) - self.value[key] = value - else: - raise IndexError - - def __len__(self): - return self.dimension - @property def x(self): return self.value[0] + @x.setter def x(self, value): self.value[0] = value @@ -107,6 +136,7 @@ def x(self, value): @property def y(self): return self.value[1] + @y.setter def y(self, value): self.value[1] = value @@ -115,13 +145,14 @@ def y(self, value): self.rect.value[1] = value -class size(point): +class Size(Point): def __repr__(self): return '' % (self.value[0], self.value[1]) @property def width(self): return self.value[0] + @width.setter def width(self, value): self.value[0] = value @@ -132,6 +163,7 @@ def width(self, value): @property def height(self): return self.value[1] + @height.setter def height(self, value): self.value[1] = value @@ -140,47 +172,28 @@ def height(self, value): self.rect.value[3] = value -class rect(object): +class Rect(Vector(4)): """Read/write a rect of two points in curly braces.""" - #crop = "{{0, 0}, {427, 259}}"; - - dimension = 4 - default = [0, 0, 0, 0] regex = re.compile('{{([-.e\d]+), ([-.e\d]+)}, {([-.e\d]+), ([-.e\d]+)}}') - def __init__(self, value = None, value2 = None): - + def __init__(self, value=None, value2=None): if value is not None and value2 is not None: - self.value = [value[0], value[1], value2[0], value2[1]] - elif value is not None and value2 is None: - value = value.replace('"', '') - self.value = [float(i) for i in self.regex.match(value).groups()] - else: - self.value = self.default + value = [value[0], value[1], value2[0], value2[1]] + super(Rect, self).__init__(value) def plistValue(self): - assert isinstance(self.value, list) and len(self.value) == self.dimension - return '"{{%s, %s}, {%s, %s}}"' % (floatToString(self.value[0], 3), floatToString(self.value[1], 3), floatToString(self.value[2], 3), floatToString(self.value[3], 3)) + assert (isinstance(self.value, list) + and len(self.value) == self.dimension) + return '"{{%s, %s}, {%s, %s}}"' % tuple( + floatToString(v, 3) for v in self.value) def __repr__(self): return '' % (str(self.origin), str(self.size)) - def __getitem__(self, key): - return self.value[key] - - def __setitem__(self, key, value): - if type(key) is int and key < self.dimension: - while self.dimension > len(self.value): - self.value.append(0) - self.value[key] = value - else: - raise KeyError - def __len__(self): - return self.dimension - @property def origin(self): - return point(self.value[0], self.value[1], rect = self) + return Point(self.value[0], self.value[1], rect=self) + @origin.setter def origin(self, value): self.value[0] = value.x @@ -188,28 +201,26 @@ def origin(self, value): @property def size(self): - return size(self.value[2], self.value[3], rect = self) + return Size(self.value[2], self.value[3], rect=self) + @size.setter def size(self, value): self.value[2] = value.width self.value[3] = value.height -class transform(point): +class Transform(Vector(6)): """Read/write a six-element vector.""" - dimension = 6 - default = [None, None, None, None, None, None] - regex = re.compile('{%s}' % ', '.join(['([-.e\d]+)'] * dimension)) - - def __init__(self, value = None, value2 = None, value3 = None, value4 = None, value5 = None, value6 = None): - - if value is not None and value2 is not None and value3 is not None and value4 is not None and value5 is not None and value6 is not None: - self.value = [value, value2, value3, value4, value5, value6] - elif value is not None and value2 is None: - value = value.replace('"', '') - self.value = [float(i) for i in self.regex.match(value).groups()] - else: - self.value = self.default + def __init__(self, + value=None, + value2=None, + value3=None, + value4=None, + value5=None, + value6=None): + if all(v is not None for v in (value, value2, value3, value4, value5, value6)): + value = [value, value2, value3, value4, value5, value6] + super(Transform, self).__init__(value) def __repr__(self): return '' % (' '.join(map(str, self.value))) @@ -220,36 +231,43 @@ def plistValue(self): return '"{%s}"' % (', '.join(floatToString(v, 5) for v in self.value)) -class glyphs_datetime(baseType): - """Read/write a datetime. Doesn't maintain time zone offset.""" +UTC_OFFSET_RE = re.compile( + r".* (?P\+|\-)(?P\d\d)(?P\d\d)$") - utc_offset_re = re.compile( - r".* (?P\+|\-)(?P\d\d)(?P\d\d)$") - - def read(self, src): - """Parse a datetime object from a string.""" - string = src.replace('"', '') - # parse timezone ourselves, since %z is not always supported - # see: http://bugs.python.org/issue6641 - m = glyphs_datetime.utc_offset_re.match(string) - if m: - sign = 1 if m.group("sign") == "+" else -1 - tz_hours = sign * int(m.group("hours")) - tz_minutes = sign * int(m.group("minutes")) - offset = datetime.timedelta(hours=tz_hours, minutes=tz_minutes) - string = string[:-6] - else: - # no explicit timezone - offset = datetime.timedelta(0) - if 'AM' in string or 'PM' in string: - datetime_obj = datetime.datetime.strptime( - string, '%Y-%m-%d %I:%M:%S %p' - ) - else: - datetime_obj = datetime.datetime.strptime( - string, '%Y-%m-%d %H:%M:%S' - ) - return datetime_obj + offset + +def parse_datetime(src=None): + """Parse a datetime object from a string.""" + if src is None: + return None + string = src.replace('"', '') + # parse timezone ourselves, since %z is not always supported + # see: http://bugs.python.org/issue6641 + m = UTC_OFFSET_RE.match(string) + if m: + sign = 1 if m.group("sign") == "+" else -1 + tz_hours = sign * int(m.group("hours")) + tz_minutes = sign * int(m.group("minutes")) + offset = datetime.timedelta(hours=tz_hours, minutes=tz_minutes) + string = string[:-6] + else: + # no explicit timezone + offset = datetime.timedelta(0) + if 'AM' in string or 'PM' in string: + datetime_obj = datetime.datetime.strptime( + string, '%Y-%m-%d %I:%M:%S %p' + ) + else: + datetime_obj = datetime.datetime.strptime( + string, '%Y-%m-%d %H:%M:%S' + ) + return datetime_obj + offset + + +# FIXME: (jany) Not sure this should be used +class Datetime(ValueType): + """Read/write a datetime. Doesn't maintain time zone offset.""" + def fromString(self, src): + return parse_datetime(src) def plistValue(self): return "\"%s +0000\"" % self.value @@ -261,27 +279,28 @@ def strftime(self, val): return None -class color(baseType): +def parse_color(src=None): + if src is None: + return None + if src[0] == "(": + src = src[1:-1] + color = src.split(",") + color = tuple([int(c) for c in color]) + else: + color = int(src) + return color - def read(self, src=None): - src.replace('"', '') - if src is None: - return None - if src[0] == "(": - src = src[1:-1] - color = src.split(",") - color = tuple([int(c) for c in color]) - else: - color = int(src) - return color + +# FIXME: (jany) not sure this is used +class Color(ValueType): + def fromString(self, src): + return parse_color(src) def __repr__(self): return self.value.__repr__() def plistValue(self): - if self.value is not None: - return str(self.value) - return None + return unicode(self.value) # mutate list in place @@ -334,3 +353,22 @@ def floatToString(Float, precision=3): return "%.0f" % Float except: print(traceback.format_exc()) + + +class UnicodesList(list): + """Represent a PLIST-able list of unicode codepoints as strings.""" + def __init__(self, value=None): + if value is None: + unicodes = [] + elif isinstance(value, (str, unicode)): + unicodes = value.split(',') + else: + unicodes = value + super(UnicodesList, self).__init__(unicodes) + + def plistValue(self): + if not self: + return None + if len(self) == 1: + return self[0] + return '"%s"' % ','.join(self) diff --git a/Lib/glyphsLib/util.py b/Lib/glyphsLib/util.py index 5502d437e..da3f9f4c1 100644 --- a/Lib/glyphsLib/util.py +++ b/Lib/glyphsLib/util.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +# TODO: (jany) merge with builder/common.py + import logging import os import shutil @@ -25,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): @@ -66,6 +68,22 @@ def cast_to_number_or_bool(inputstr): return inputstr +def reverse_cast_to_number_or_bool(input): + if input is True: + return 'true' # FIXME: (jany) dubious, glyphs handbook says should be 1 + if input is False: + return 'false' # FIXME: (jany) dubious, glyphs handbook says should be 0 + return str(input) + + def bin_to_int_list(value): string = num2binary(value) + string = string.replace(' ', '') # num2binary add a space every 8 digits return [i for i, v in enumerate(reversed(string)) if v == "1"] + + +def int_list_to_bin(value): + result = 0 + for i in value: + result += 1 << i + return result diff --git a/Lib/glyphsLib/writer.py b/Lib/glyphsLib/writer.py index 57db7e040..0a3b366a1 100644 --- a/Lib/glyphsLib/writer.py +++ b/Lib/glyphsLib/writer.py @@ -116,15 +116,15 @@ def writeUserData(self, userDataValue): self.file.write("}") def writeValue(self, value, forKey=None, forType=None): - if isinstance(value, (list, glyphsLib.classes.Proxy)): + if hasattr(value, "plistValue"): + value = value.plistValue() + if value is not None: + self.file.write(value) + elif isinstance(value, (list, glyphsLib.classes.Proxy)): if isinstance(value, glyphsLib.classes.UserDataProxy): self.writeUserData(value) else: self.writeArray(value) - elif hasattr(value, "plistValue"): - value = value.plistValue() - if value is not None: - self.file.write(value) elif isinstance(value, (dict, OrderedDict, glyphsLib.classes.GSBase)): self.writeDict(value) elif type(value) == float: diff --git a/README.rst b/README.rst index 048fc7a96..264cde366 100644 --- a/README.rst +++ b/README.rst @@ -42,9 +42,9 @@ Read and write Glyphs data as Python objects .. code:: python from glyphsLib import GSFont - + font = GSFont(glyphs_file) - + font.save(glyphs_file) The ``glyphsLib.classes`` module aims to provide an interface similar to @@ -57,6 +57,78 @@ is missing or does not work as expected, please open a issue. .. TODO Briefly state how much of the Glyphs.app API is currently covered, and what is not supported yet. +Go back and forth between UFOs and Glyphs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +1. Without a designspace file, using for example the `Inria fonts by Black[Foundry] `__: + +.. code:: python + + import glob + from defcon import Font + from glyphsLib import to_glyphs + ufos = [Font(path) for path in glob.glob('*Italic.ufo')] + # Sort the UFOs because glyphsLib will create masters in the same order + ufos = sorted(ufos, key=lambda ufo: ufo.info.openTypeOS2WeightClass) + font = to_glyphs(ufos) + font.save('InriaSansItalic.glyphs') + +`Here is the resulting glyphs file `__ + +2. With a designspace, using `Spectral from Production Type `__: + +.. code:: python + + import glob + from fontTools.designspaceLib import DesignSpaceDocument + from glyphsLib import to_glyphs + doc = DesignSpaceDocument() + doc.read('spectral-build-roman.designspace') + font = to_glyphs(doc) + font.save('SpectralRoman.glyphs') + +`Here is the resulting glyphs file `__ + +3. In both cases, if you intend to go back to UFOs after modifying the file +with Glyphs, you should use the ``minimize_ufo_diffs`` parameter to minimize +the amount of diffs that will show up in git after the back and forth. To do +so, the glyphsLib will add some bookkeeping values in various ``userData`` +fields. For example, it will try to remember which GSClass came from +groups.plist or from the feature file. + +The same option exists for people who want to do Glyphs->UFOs->Glyphs: +``minimize_glyphs_diffs``, which will add some bookkeeping data in UFO ``lib``. +For example, it will keep the same UUIDs for Glyphs layers, and so will need +to store those layer UUIDs in the UFOs. + +.. code:: python + + import glob + import os + from fontTools.designspaceLib import DesignSpaceDocument + from glyphsLib import to_glyphs, to_designspace, GSFont + doc = DesignSpaceDocument() + doc.read('spectral-build-roman.designspace') + font = to_glyphs(doc, minimize_ufo_diffs=True) + doc2 = to_designspace(font, propagate_anchors=False) + # UFOs are in memory only, attached to the doc via `sources` + # Writing doc2 over the original doc should generate very few git diffs (ideally none) + doc2.write(doc.path) + for source in doc2.sources: + path = os.path.join(os.path.dirname(doc.path), source.filename) + # You will want to use ufoNormalizer after + source.font.save(path) + + font = GSFont('SpectralRoman.glyphs') + doc = to_designspace(font, minimize_glyphs_diffs=True, propagate_anchors=False) + font2 = to_glyphs(doc) + # Writing font2 over font should generate very few git diffs (ideally none): + font2.save(font.filepath) + +In practice there are always a few diffs on things that don't really make a +difference, like optional things being added/removed or whitespace changes or +things getting reordered... + .. |Travis Build Status| image:: https://travis-ci.org/googlei18n/glyphsLib.svg :target: https://travis-ci.org/googlei18n/glyphsLib .. |PyPI Version| image:: https://img.shields.io/pypi/v/glyphsLib.svg diff --git a/requirements.txt b/requirements.txt index 21c4b5cc1..c272ef37c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -fonttools==3.22.0 +fonttools==3.24.0 defcon==0.3.5 MutatorMath==2.1.0 diff --git a/setup.py b/setup.py index 5339967ef..f9549c6ba 100644 --- a/setup.py +++ b/setup.py @@ -156,6 +156,7 @@ def run(self): test_requires = [ 'pytest>=2.8', + 'ufoNormalizer>=0.3.2', ] if sys.version_info < (3, 3): test_requires.append('mock>=2.0.0') @@ -179,7 +180,7 @@ def run(self): setup_requires=pytest_runner + wheel + bump2version, tests_require=test_requires, install_requires=[ - "fonttools>=3.4.0", + "fonttools>=3.24.0", "defcon>=0.3.0", "MutatorMath>=2.0.4", ], diff --git a/tests/all_to_ufos.py b/tests/all_to_ufos.py new file mode 100644 index 000000000..b73c0a6b0 --- /dev/null +++ b/tests/all_to_ufos.py @@ -0,0 +1,52 @@ +# 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. + +import argparse +import os + +import glyphsLib + + +def glyphs_files(directory): + for root, _dirs, files in os.walk(directory): + for filename in files: + if filename.endswith('.glyphs'): + yield os.path.join(root, filename) + + +def main(): + parser = argparse.ArgumentParser("Translate all .glyphs files into UFO+designspace in the specified directories.") + parser.add_argument('-o', '--out', metavar='OUTPUT_DIR', default='ufos', + help='Output directory') + parser.add_argument('directories', nargs='*') + args = parser.parse_args() + + for directory in args.directories: + files = glyphs_files(directory) + for filename in files: + try: + # Code for glyphsLib with roundtrip + from glyphsLib.builder import to_designspace + font = glyphsLib.GSFont(filename) + designspace = to_designspace(font) + dsname = font.familyName.replace(' ', '') + '.designspace' + designspace.write(os.path.join(args.out, dsname)) + except ImportError: + # This is the version that works with glyphsLib 2.1.0 + glyphsLib.build_masters(filename, master_dir=args.out, + designspace_instance_dir=args.out) + +if __name__ == '__main__': + main() + diff --git a/tests/builder_test.py b/tests/builder/builder_test.py similarity index 81% rename from tests/builder_test.py rename to tests/builder/builder_test.py index 77bf502dd..b7c085570 100644 --- a/tests/builder_test.py +++ b/tests/builder/builder_test.py @@ -23,13 +23,9 @@ import io import logging import unittest -# unittest.mock is only available for python 3.3+ -try: - from unittest import mock - from unittest.mock import patch -except ImportError: - from mock import patch - import mock +import tempfile +import os +import shutil from defcon import Font from fontTools.misc.loggingTools import CapturingLogHandler @@ -37,17 +33,17 @@ from glyphsLib.classes import ( GSFont, GSFontMaster, GSInstance, GSCustomParameter, GSGlyph, GSLayer, GSPath, GSNode, GSAnchor, GSComponent, GSAlignmentZone, GSGuideLine) -from glyphsLib.types import point +from glyphsLib.types import Point -from glyphsLib.builder import to_ufos -from glyphsLib.builder.paths import to_ufo_draw_paths -from glyphsLib.builder.custom_params import (set_custom_params, - set_default_params) +from glyphsLib.builder import to_ufos, to_glyphs +from glyphsLib.builder.builders import UFOBuilder, GlyphsBuilder +from glyphsLib.builder.paths import to_ufo_paths from glyphsLib.builder.names import build_stylemap_names, build_style_name from glyphsLib.builder.filters import parse_glyphs_filter from glyphsLib.builder.constants import ( GLYPHS_PREFIX, PUBLIC_PREFIX, GLYPHLIB_PREFIX, - UFO2FT_USE_PROD_NAMES_KEY) + UFO2FT_USE_PROD_NAMES_KEY, FONT_CUSTOM_PARAM_PREFIX, + MASTER_CUSTOM_PARAM_PREFIX) from classes_test import (generate_minimal_font, generate_instance_from_dict, add_glyph, add_anchor, add_component) @@ -276,166 +272,6 @@ def test_linked_style_bold_italic(self): self.assertEqual("bold italic", map_style) -class SetCustomParamsTest(unittest.TestCase): - def setUp(self): - self.ufo = Font() - - def test_normalizes_curved_quotes_in_names(self): - master = GSFontMaster() - master.customParameters = [GSCustomParameter(name='‘bad’', value=1), - GSCustomParameter(name='“also bad”', value=2)] - set_custom_params(self.ufo, data=master) - self.assertIn(GLYPHS_PREFIX + "'bad'", self.ufo.lib) - self.assertIn(GLYPHS_PREFIX + '"also bad"', self.ufo.lib) - - def test_set_glyphOrder(self): - set_custom_params(self.ufo, parsed=[('glyphOrder', ['A', 'B'])]) - self.assertEqual(self.ufo.lib[PUBLIC_PREFIX + 'glyphOrder'], ['A', 'B']) - - def test_set_fsSelection_flags(self): - self.assertEqual(self.ufo.info.openTypeOS2Selection, None) - - set_custom_params(self.ufo, parsed=[('Has WWS Names', False)]) - self.assertEqual(self.ufo.info.openTypeOS2Selection, None) - - set_custom_params(self.ufo, parsed=[('Use Typo Metrics', True)]) - self.assertEqual(self.ufo.info.openTypeOS2Selection, [7]) - - self.ufo = Font() - set_custom_params(self.ufo, parsed=[('Has WWS Names', True), - ('Use Typo Metrics', True)]) - self.assertEqual(self.ufo.info.openTypeOS2Selection, [8, 7]) - - def test_underlinePosition(self): - set_custom_params(self.ufo, parsed=[('underlinePosition', -2)]) - self.assertEqual(self.ufo.info.postscriptUnderlinePosition, -2) - - set_custom_params(self.ufo, parsed=[('underlinePosition', 1)]) - self.assertEqual(self.ufo.info.postscriptUnderlinePosition, 1) - - def test_underlineThickness(self): - set_custom_params(self.ufo, parsed=[('underlineThickness', 100)]) - self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 100) - - set_custom_params(self.ufo, parsed=[('underlineThickness', 0)]) - self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 0) - - @patch('glyphsLib.builder.custom_params.parse_glyphs_filter') - def test_parse_glyphs_filter(self, mock_parse_glyphs_filter): - filter1 = ('PreFilter', 'AddExtremes') - filter2 = ( - 'Filter', - 'Transformations;OffsetX:40;OffsetY:60;include:uni0334,uni0335') - filter3 = ( - 'Filter', - 'Transformations;OffsetX:10;OffsetY:-10;exclude:uni0334,uni0335') - set_custom_params(self.ufo, parsed=[filter1, filter2, filter3]) - - self.assertEqual(mock_parse_glyphs_filter.call_count, 3) - self.assertEqual(mock_parse_glyphs_filter.call_args_list[0], - mock.call(filter1[1], is_pre=True)) - self.assertEqual(mock_parse_glyphs_filter.call_args_list[1], - mock.call(filter2[1], is_pre=False)) - self.assertEqual(mock_parse_glyphs_filter.call_args_list[2], - mock.call(filter3[1], is_pre=False)) - - def test_set_defaults(self): - set_default_params(self.ufo) - self.assertEqual(self.ufo.info.openTypeOS2Type, [3]) - self.assertEqual(self.ufo.info.postscriptUnderlinePosition, -100) - self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 50) - - def test_set_codePageRanges(self): - set_custom_params(self.ufo, parsed=[('codePageRanges', [1252, 1250])]) - self.assertEqual(self.ufo.info.openTypeOS2CodePageRanges, [0, 1]) - - def test_set_openTypeOS2CodePageRanges(self): - set_custom_params(self.ufo, parsed=[ - ('openTypeOS2CodePageRanges', [0, 1])]) - self.assertEqual(self.ufo.info.openTypeOS2CodePageRanges, [0, 1]) - - def test_gasp_table(self): - gasp_table = {'65535': '15', - '20': '7', - '8': '10'} - set_custom_params(self.ufo, parsed=[('GASP Table', gasp_table)]) - - ufo_range_records = self.ufo.info.openTypeGaspRangeRecords - self.assertIsNotNone(ufo_range_records) - self.assertEqual(len(ufo_range_records), 3) - rec1, rec2, rec3 = ufo_range_records - self.assertEqual(rec1['rangeMaxPPEM'], 8) - self.assertEqual(rec1['rangeGaspBehavior'], [1, 3]) - self.assertEqual(rec2['rangeMaxPPEM'], 20) - self.assertEqual(rec2['rangeGaspBehavior'], [0, 1, 2]) - self.assertEqual(rec3['rangeMaxPPEM'], 65535) - self.assertEqual(rec3['rangeGaspBehavior'], [0, 1, 2, 3]) - - def test_set_disables_nice_names(self): - set_custom_params(self.ufo, parsed=[('disablesNiceNames', False)]) - self.assertEqual(True, self.ufo.lib[GLYPHS_PREFIX + 'useNiceNames']) - - def test_set_disable_last_change(self): - set_custom_params(self.ufo, parsed=[('Disable Last Change', True)]) - self.assertEqual(True, - self.ufo.lib[GLYPHS_PREFIX + 'disablesLastChange']) - - def test_xHeight(self): - set_custom_params(self.ufo, parsed=[('xHeight', '500')]) - self.assertEqual(self.ufo.info.xHeight, 500) - - def test_replace_feature(self): - self.ufo.features.text = dedent(""" - feature liga { - # only the first match is replaced - sub f i by fi; - } liga; - - feature calt { - sub e' t' c by ampersand; - } calt; - - feature liga { - sub f l by fl; - } liga; - """) - - repl = "liga; sub f f by ff;" - - set_custom_params(self.ufo, parsed=[("Replace Feature", repl)]) - - self.assertEqual(self.ufo.features.text, dedent(""" - feature liga { - sub f f by ff; - } liga; - - feature calt { - sub e' t' c by ampersand; - } calt; - - feature liga { - sub f l by fl; - } liga; - """)) - - # only replace feature body if tag already present - original = self.ufo.features.text - repl = "numr; sub one by one.numr;\nsub two by two.numr;\n" - - set_custom_params(self.ufo, parsed=[("Replace Feature", repl)]) - - self.assertEqual(self.ufo.features.text, original) - - def test_useProductionNames(self): - for value in (True, False): - glyphs_param = ("Don't use Production Names", value) - set_custom_params(self.ufo, parsed=[glyphs_param]) - - self.assertIn(UFO2FT_USE_PROD_NAMES_KEY, self.ufo.lib) - self.assertEqual(self.ufo.lib[UFO2FT_USE_PROD_NAMES_KEY], - not value) - - class ParseGlyphsFilterTest(unittest.TestCase): def test_complete_parameter(self): inputstr = 'Transformations;LSB:+23;RSB:-22;SlantCorrection:true;OffsetX:10;OffsetY:-10;Origin:0;exclude:uni0334,uni0335 uni0336' @@ -792,26 +628,6 @@ def test_set_blue_values(self): self.assertEqual(ufo.info.postscriptBlueValues, expected_blue_values) self.assertEqual(ufo.info.postscriptOtherBlues, expected_other_blues) - def test_set_glyphOrder_no_custom_param(self): - font = generate_minimal_font() - add_glyph(font, 'C') - add_glyph(font, 'B') - add_glyph(font, 'A') - add_glyph(font, 'Z') - glyphOrder = to_ufos(font)[0].lib[PUBLIC_PREFIX + 'glyphOrder'] - self.assertEqual(glyphOrder, ['C', 'B', 'A', 'Z']) - - def test_set_glyphOrder_with_custom_param(self): - font = generate_minimal_font() - font.customParameters['glyphOrder'] = ['A', 'B', 'C'] - add_glyph(font, 'C') - add_glyph(font, 'B') - add_glyph(font, 'A') - # glyphs outside glyphOrder are appended at the end - add_glyph(font, 'Z') - glyphOrder = to_ufos(font)[0].lib[PUBLIC_PREFIX + 'glyphOrder'] - self.assertEqual(glyphOrder, ['A', 'B', 'C', 'Z']) - def test_missing_date(self): font = generate_minimal_font() font.date = None @@ -826,8 +642,8 @@ def test_variation_font_origin(self): ufos, instances = to_ufos(font, include_instances=True) + key = FONT_CUSTOM_PARAM_PREFIX + name for ufo in ufos: - key = GLYPHS_PREFIX + name self.assertIn(key, ufo.lib) self.assertEqual(ufo.lib[key], value) self.assertIn(name, instances) @@ -974,7 +790,8 @@ def test_coerce_to_bool(self): font = generate_minimal_font() font.customParameters['Disable Last Change'] = 'Truthy' ufo = to_ufos(font)[0] - self.assertEqual(True, ufo.lib[GLYPHS_PREFIX + 'disablesLastChange']) + self.assertEqual(True, ufo.lib[FONT_CUSTOM_PARAM_PREFIX + + 'disablesLastChange']) def _run_guideline_test(self, data_in, expected): font = generate_minimal_font() @@ -984,7 +801,7 @@ def _run_guideline_test(self, data_in, expected): layer.layerId = font.masters[0].id layer.width = 0 for guide_data in data_in: - pt = point(value=guide_data['position'][0], + pt = Point(value=guide_data['position'][0], value2=guide_data['position'][1]) guide = GSGuideLine() guide.position = pt @@ -1055,11 +872,14 @@ def test_glyph_lib_metricsKeys(self): ufo = to_ufos(font)[0] - self.assertEqual(ufo["x"].lib[GLYPHLIB_PREFIX + "leftMetricsKey"], "y") - self.assertEqual(ufo["x"].lib[GLYPHLIB_PREFIX + "rightMetricsKey"], "z") - self.assertNotIn(GLYPHLIB_PREFIX + "widthMetricsKey", ufo["x"].lib) + self.assertEqual( + ufo["x"].lib[GLYPHLIB_PREFIX + "glyph.leftMetricsKey"], "y") + self.assertEqual( + ufo["x"].lib[GLYPHLIB_PREFIX + "glyph.rightMetricsKey"], "z") + self.assertNotIn( + GLYPHLIB_PREFIX + "glyph.widthMetricsKey", ufo["x"].lib) - def test_glyph_lib_componentsAlignment_and_componentsLocked(self): + def test_glyph_lib_component_alignment_and_locked_and_smart_values(self): font = generate_minimal_font() add_glyph(font, "a") add_glyph(font, "b") @@ -1071,15 +891,19 @@ def test_glyph_lib_componentsAlignment_and_componentsLocked(self): self.assertEqual(comp1.alignment, 0) self.assertEqual(comp1.locked, False) + self.assertEqual(comp1.smartComponentValues, {}) ufo = to_ufos(font)[0] # all components have deault values, no lib key is written self.assertNotIn(GLYPHS_PREFIX + "componentsAlignment", ufo["c"].lib) self.assertNotIn(GLYPHS_PREFIX + "componentsLocked", ufo["c"].lib) + self.assertNotIn(GLYPHS_PREFIX + "componentsSmartComponentValues", + ufo["c"].lib) comp2.alignment = -1 comp1.locked = True + comp1.smartComponentValues['height'] = 0 ufo = to_ufos(font)[0] # if any component has a non-default alignment/locked values, write @@ -1090,6 +914,42 @@ def test_glyph_lib_componentsAlignment_and_componentsLocked(self): self.assertIn(GLYPHS_PREFIX + "componentsLocked", ufo["c"].lib) self.assertEqual( ufo["c"].lib[GLYPHS_PREFIX + "componentsLocked"], [True, False]) + self.assertIn(GLYPHS_PREFIX + "componentsSmartComponentValues", + ufo["c"].lib) + self.assertEqual( + ufo["c"].lib[GLYPHS_PREFIX + "componentsSmartComponentValues"], + [{'height': 0}, {}]) + + def test_master_with_light_weight_but_thin_name(self): + font = generate_minimal_font() + master = font.masters[0] + name = 'Thin' # In Glyphs.app, show "Thin" in the sidebar + weight = 'Light' # In Glyphs.app, have the light "n" icon + width = None # No data => should be equivalent to Regular + custom_name = 'Thin' + master.set_all_name_components(name, weight, width, custom_name) + assert master.name == 'Thin' + assert master.weight == 'Light' + + ufo, = to_ufos(font) + font_rt = to_glyphs([ufo]) + master_rt = font_rt.masters[0] + + assert master_rt.name == 'Thin' + assert master_rt.weight == 'Light' + + tmpdir = tempfile.mkdtemp() + try: + filename = os.path.join(tmpdir, 'test.glyphs') + font_rt.save(filename) + font_rt_written = GSFont(filename) + + master_rt_written = font_rt_written.masters[0] + + assert master_rt_written.name == 'Thin' + assert master_rt_written.weight == 'Light' + finally: + shutil.rmtree(tmpdir) class _PointDataPen(object): @@ -1111,17 +971,32 @@ def addComponent(self, *args, **kwargs): pass +class _Glyph(object): + def __init__(self): + self.pen = _PointDataPen() + + def getPointPen(self): + return self.pen + + +class _UFOBuilder(object): + def to_ufo_node_user_data(self, *args): + pass + + class DrawPathsTest(unittest.TestCase): def test_to_ufo_draw_paths_empty_nodes(self): - contours = [GSPath()] + layer = GSLayer() + layer.paths.append(GSPath()) - pen = _PointDataPen() - to_ufo_draw_paths(None, pen, contours) + glyph = _Glyph() + to_ufo_paths(_UFOBuilder(), glyph, layer) - self.assertEqual(pen.contours, []) + self.assertEqual(glyph.pen.contours, []) def test_to_ufo_draw_paths_open(self): + layer = GSLayer() path = GSPath() path.nodes = [ GSNode(position=(0, 0), nodetype='line'), @@ -1130,10 +1005,11 @@ def test_to_ufo_draw_paths_open(self): GSNode(position=(3, 3), nodetype='curve', smooth=True), ] path.closed = False - pen = _PointDataPen() - to_ufo_draw_paths(None, pen, [path]) + layer.paths.append(path) + glyph = _Glyph() + to_ufo_paths(_UFOBuilder(), glyph, layer) - self.assertEqual(pen.contours, [[ + self.assertEqual(glyph.pen.contours, [[ (0, 0, 'move', False), (1, 1, None, False), (2, 2, None, False), @@ -1141,6 +1017,7 @@ def test_to_ufo_draw_paths_open(self): ]]) def test_to_ufo_draw_paths_closed(self): + layer = GSLayer() path = GSPath() path.nodes = [ GSNode(position=(0, 0), nodetype='offcurve'), @@ -1151,11 +1028,12 @@ def test_to_ufo_draw_paths_closed(self): GSNode(position=(5, 5), nodetype='curve', smooth=True), ] path.closed = True + layer.paths.append(path) - pen = _PointDataPen() - to_ufo_draw_paths(None, pen, [path]) + glyph = _Glyph() + to_ufo_paths(_UFOBuilder(), glyph, layer) - points = pen.contours[0] + points = glyph.pen.contours[0] first_x, first_y = points[0][:2] self.assertEqual((first_x, first_y), (5, 5)) @@ -1164,6 +1042,7 @@ def test_to_ufo_draw_paths_closed(self): self.assertEqual(first_segment_type, 'curve') def test_to_ufo_draw_paths_qcurve(self): + layer = GSLayer() path = GSPath() path.nodes = [ GSNode(position=(143, 695), nodetype='offcurve'), @@ -1173,11 +1052,12 @@ def test_to_ufo_draw_paths_qcurve(self): GSNode(position=(223, 334), nodetype='qcurve', smooth=True), ] path.closed = True + layer.paths.append(path) - pen = _PointDataPen() - to_ufo_draw_paths(None, pen, [path]) + glyph = _Glyph() + to_ufo_paths(_UFOBuilder(), glyph, layer) - points = pen.contours[0] + points = glyph.pen.contours[0] first_x, first_y = points[0][:2] self.assertEqual((first_x, first_y), (223, 334)) @@ -1258,6 +1138,76 @@ def test_dangling_layer(self): captor.assertRegex("is dangling and will be skipped") +class GlyphOrderTest(unittest.TestCase): + """Check that the glyphOrder data is persisted correctly in all directions. + + In Glyphs, there are two pieces of information to save: + * The custom parameter 'glyphOrder', if provided + * The actual order of glyphs in the font. + + We need both because: + * There can be glyphs in the font that are not in the glyphOrder + * Those extraneous glyphs are ordered automatically by Glyphs.app but + we don't know how, so if we want to reproduce the original ordering + with glyphsLib, we must store it. + FIXME: ask Georg how/whether we can order like Glyphs.app in glyphsLib + + To match the Glyphs.app export/import behaviour, we will: + * set UFO's public.glyphOrder to the actual order in the font + * store the custom parameter 'glyphOrder' in UFO's lib, if provided. + """ + def setUp(self): + self.font = GSFont() + self.font.masters.append(GSFontMaster()) + self.font.glyphs.append(GSGlyph('a')) + self.font.glyphs.append(GSGlyph('c')) + self.font.glyphs.append(GSGlyph('f')) + self.ufo = Font() + self.ufo.newGlyph('a') + self.ufo.newGlyph('c') + self.ufo.newGlyph('f') + + def from_glyphs(self): + builder = UFOBuilder(self.font) + return next(iter(builder.masters)) + + def from_ufo(self): + builder = GlyphsBuilder([self.ufo]) + return builder.font + + def test_from_glyphs_no_custom_order(self): + ufo = self.from_glyphs() + self.assertEqual(['a', 'c', 'f'], ufo.glyphOrder) + self.assertNotIn(GLYPHS_PREFIX + 'glyphOrder', ufo.lib) + + def test_from_glyphs_with_custom_order(self): + self.font.customParameters['glyphOrder'] = ['a', 'b', 'c', 'd'] + ufo = self.from_glyphs() + self.assertEqual(['a', 'c', 'f'], ufo.glyphOrder) + self.assertEqual(['a', 'b', 'c', 'd'], + ufo.lib[GLYPHS_PREFIX + 'glyphOrder']) + + def test_from_ufo_partial_public_order_no_custom_order(self): + """When we import a UFO that was not produced by glyphsLib. + If there was more than just 'f' that is not included in + public.glyphOrder, we could not know how to order them like Glyphs.app + """ + self.ufo.glyphOrder = ['a', 'b', 'c', 'd'] + font = self.from_ufo() + self.assertEqual(['a', 'c', 'f'], + list(glyph.name for glyph in font.glyphs)) + self.assertNotIn('glyphOrder', font.customParameters) + + def test_from_ufo_complete_public_order_with_custom_order(self): + """Import a UFO generated by glyphsLib""" + self.ufo.glyphOrder = ['a', 'c', 'f'] + self.ufo.lib[GLYPHS_PREFIX + 'glyphOrder'] = ['a', 'b', 'c', 'd'] + font = self.from_ufo() + self.assertEqual(['a', 'c', 'f'], + list(glyph.name for glyph in font.glyphs)) + self.assertEqual(['a', 'b', 'c', 'd'], + font.customParameters['glyphOrder']) + if __name__ == '__main__': unittest.main() diff --git a/tests/builder/custom_params_test.py b/tests/builder/custom_params_test.py new file mode 100644 index 000000000..e5105c53f --- /dev/null +++ b/tests/builder/custom_params_test.py @@ -0,0 +1,268 @@ +# coding=UTF-8 +# +# Copyright 2016 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 textwrap import dedent +import unittest +# unittest.mock is only available for python 3.3+ +try: + from unittest import mock + from unittest.mock import patch +except ImportError: + from mock import patch + import mock + +from defcon import Font +from glyphsLib.builder.builders import UFOBuilder, GlyphsBuilder +from glyphsLib.builder.custom_params import (to_ufo_custom_params, + _set_default_params) +from glyphsLib.builder.constants import ( + GLYPHS_PREFIX, PUBLIC_PREFIX, GLYPHLIB_PREFIX, + UFO2FT_USE_PROD_NAMES_KEY, FONT_CUSTOM_PARAM_PREFIX, + MASTER_CUSTOM_PARAM_PREFIX) +from glyphsLib.classes import ( + GSFont, GSFontMaster, GSInstance, GSCustomParameter, GSGlyph, GSLayer, + GSPath, GSNode, GSAnchor, GSComponent, GSAlignmentZone, GSGuideLine) + + +class SetCustomParamsTest(unittest.TestCase): + def setUp(self): + self.ufo = Font() + self.font = GSFont() + self.master = GSFontMaster() + self.font.masters.insert(0, self.master) + self.builder = UFOBuilder(self.font) + + def set_custom_params(self): + self.builder.to_ufo_custom_params(self.ufo, self.font) + self.builder.to_ufo_custom_params(self.ufo, self.master) + + def test_normalizes_curved_quotes_in_names(self): + self.master.customParameters = [ + GSCustomParameter(name='‘bad’', value=1), + GSCustomParameter(name='“also bad”', value=2), + ] + self.set_custom_params() + self.assertIn(MASTER_CUSTOM_PARAM_PREFIX + "'bad'", self.ufo.lib) + self.assertIn(MASTER_CUSTOM_PARAM_PREFIX + '"also bad"', self.ufo.lib) + + def test_set_glyphOrder(self): + self.master.customParameters['glyphOrder'] = ['A', 'B'] + self.set_custom_params() + self.assertEqual(self.ufo.lib[GLYPHS_PREFIX + 'glyphOrder'], ['A', 'B']) + + def test_set_fsSelection_flags(self): + self.assertEqual(self.ufo.info.openTypeOS2Selection, None) + + self.master.customParameters['Has WWS Names'] = False + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2Selection, None) + + self.master.customParameters['Use Typo Metrics'] = True + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2Selection, [7]) + + self.ufo = Font() + self.master.customParameters = [ + GSCustomParameter(name='Use Typo Metrics', value=True), + GSCustomParameter(name='Has WWS Names', value=True), + ] + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2Selection, [8, 7]) + + def test_underlinePosition(self): + self.master.customParameters['underlinePosition'] = -2 + self.set_custom_params() + self.assertEqual(self.ufo.info.postscriptUnderlinePosition, -2) + + # self.master.customParameters['underlinePosition'] = 1 + for param in self.master.customParameters: + if param.name == 'underlinePosition': + param.value = 1 + break + self.set_custom_params() + self.assertEqual(self.ufo.info.postscriptUnderlinePosition, 1) + + def test_underlineThickness(self): + self.master.customParameters['underlineThickness'] = 100 + self.set_custom_params() + self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 100) + + # self.master.customParameters['underlineThickness'] = 0 + for param in self.master.customParameters: + if param.name == 'underlineThickness': + param.value = 0 + break + self.set_custom_params() + self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 0) + + @patch('glyphsLib.builder.custom_params.parse_glyphs_filter') + def test_parse_glyphs_filter(self, mock_parse_glyphs_filter): + pre_filter = 'AddExtremes' + filter1 = 'Transformations;OffsetX:40;OffsetY:60;include:uni0334,uni0335' + filter2 = 'Transformations;OffsetX:10;OffsetY:-10;exclude:uni0334,uni0335' + self.master.customParameters.extend([ + GSCustomParameter(name='PreFilter', value=pre_filter), + GSCustomParameter(name='Filter', value=filter1), + GSCustomParameter(name='Filter', value=filter2), + ]) + self.set_custom_params() + + self.assertEqual(mock_parse_glyphs_filter.call_count, 3) + self.assertEqual(mock_parse_glyphs_filter.call_args_list[0], + mock.call(pre_filter, is_pre=True)) + self.assertEqual(mock_parse_glyphs_filter.call_args_list[1], + mock.call(filter1, is_pre=False)) + self.assertEqual(mock_parse_glyphs_filter.call_args_list[2], + mock.call(filter2, is_pre=False)) + + def test_set_defaults(self): + _set_default_params(self.ufo) + self.assertEqual(self.ufo.info.openTypeOS2Type, [3]) + self.assertEqual(self.ufo.info.postscriptUnderlinePosition, -100) + self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 50) + + def test_set_codePageRanges(self): + self.font.customParameters['codePageRanges'] = [1252, 1250] + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2CodePageRanges, [0, 1]) + + def test_set_openTypeOS2CodePageRanges(self): + self.font.customParameters['openTypeOS2CodePageRanges'] = [0, 1] + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2CodePageRanges, [0, 1]) + + def test_gasp_table(self): + gasp_table = {'65535': '15', + '20': '7', + '8': '10'} + self.font.customParameters['GASP Table'] = gasp_table + self.set_custom_params() + + ufo_range_records = self.ufo.info.openTypeGaspRangeRecords + self.assertIsNotNone(ufo_range_records) + self.assertEqual(len(ufo_range_records), 3) + rec1, rec2, rec3 = ufo_range_records + self.assertEqual(rec1['rangeMaxPPEM'], 8) + self.assertEqual(rec1['rangeGaspBehavior'], [1, 3]) + self.assertEqual(rec2['rangeMaxPPEM'], 20) + self.assertEqual(rec2['rangeGaspBehavior'], [0, 1, 2]) + self.assertEqual(rec3['rangeMaxPPEM'], 65535) + self.assertEqual(rec3['rangeGaspBehavior'], [0, 1, 2, 3]) + + def test_set_disables_nice_names(self): + self.font.disablesNiceNames = False + self.set_custom_params() + self.assertEqual(True, self.ufo.lib[FONT_CUSTOM_PARAM_PREFIX + + 'useNiceNames']) + + def test_set_disable_last_change(self): + self.font.customParameters['Disable Last Change'] = True + self.set_custom_params() + self.assertEqual(True, self.ufo.lib[FONT_CUSTOM_PARAM_PREFIX + + 'disablesLastChange']) + + # https://github.com/googlei18n/glyphsLib/issues/268 + def test_xHeight(self): + self.ufo.info.xHeight = 300 + self.master.customParameters['xHeight'] = '500' + self.set_custom_params() + # Additional xHeight values are Glyphs-specific and stored in lib + self.assertEqual(self.ufo.lib[MASTER_CUSTOM_PARAM_PREFIX + 'xHeight'], + '500') + # The xHeight from the property is not modified + self.assertEqual(self.ufo.info.xHeight, 300) + # TODO: (jany) check that the instance custom param wins over the + # interpolated value + + def test_replace_feature(self): + self.ufo.features.text = dedent(""" + feature liga { + # only the first match is replaced + sub f i by fi; + } liga; + + feature calt { + sub e' t' c by ampersand; + } calt; + + feature liga { + sub f l by fl; + } liga; + """) + + repl = "liga; sub f f by ff;" + + self.master.customParameters["Replace Feature"] = repl + self.set_custom_params() + + self.assertEqual(self.ufo.features.text, dedent(""" + feature liga { + sub f f by ff; + } liga; + + feature calt { + sub e' t' c by ampersand; + } calt; + + feature liga { + sub f l by fl; + } liga; + """)) + + # only replace feature body if tag already present + original = self.ufo.features.text + repl = "numr; sub one by one.numr;\nsub two by two.numr;\n" + + self.master.customParameters["Replace Feature"] = repl + self.set_custom_params() + + self.assertEqual(self.ufo.features.text, original) + + def test_useProductionNames(self): + for value in (True, False): + self.master.customParameters["Don't use Production Names"] = value + self.set_custom_params() + + self.assertIn(UFO2FT_USE_PROD_NAMES_KEY, self.ufo.lib) + self.assertEqual(self.ufo.lib[UFO2FT_USE_PROD_NAMES_KEY], + not value) + + def test_default_fstype(self): + # No specified fsType => set default value + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2Type, [3]) + + def test_set_fstype(self): + # Set another fsType => store that + self.master.customParameters["fsType"] = [2] + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2Type, [2]) + + def test_empty_fstype(self): + # Set empty fsType => store empty + self.master.customParameters["fsType"] = [] + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeOS2Type, []) + + def test_version_string(self): + # TODO: (jany) test the automatic replacement that is described in the + # Glyphs Handbook + self.font.customParameters['versionString'] = 'Version 2.040' + self.set_custom_params() + self.assertEqual(self.ufo.info.openTypeNameVersion, 'Version 2.040') diff --git a/tests/builder/designspace_gen_test.py b/tests/builder/designspace_gen_test.py new file mode 100644 index 000000000..3962e87e4 --- /dev/null +++ b/tests/builder/designspace_gen_test.py @@ -0,0 +1,130 @@ +# 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 os + +import pytest +import defcon + +from glyphsLib import to_glyphs, to_designspace + + +def test_designspace_generation_regular_same_family_name(tmpdir): + ufo_Lt = defcon.Font() + ufo_Lt.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Lt.info.styleName = 'Light' + ufo_Lt.info.openTypeOS2WeightClass = 300 + + ufo_Rg = defcon.Font() + ufo_Rg.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Rg.info.styleName = 'Regular' + ufo_Rg.info.openTypeOS2WeightClass = 400 + + ufo_Md = defcon.Font() + ufo_Md.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Md.info.styleName = 'Medium' + ufo_Md.info.openTypeOS2WeightClass = 500 + + ufo_Bd = defcon.Font() + ufo_Bd.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Bd.info.styleName = 'Bold' + ufo_Bd.info.openTypeOS2WeightClass = 700 + + ufo_ExBd = defcon.Font() + ufo_ExBd.info.familyName = 'CoolFoundry Examplary Serif' + ufo_ExBd.info.styleName = 'XBold' + ufo_ExBd.info.openTypeOS2WeightClass = 800 + + font = to_glyphs([ufo_Lt, ufo_Rg, ufo_Md, ufo_Bd, ufo_ExBd]) + designspace = to_designspace(font) + + path = os.path.join(str(tmpdir), 'actual.designspace') + designspace.write(path) + with open(path) as fp: + actual = fp.read() + + expected_path = os.path.join(os.path.dirname(__file__), '..', 'data', + 'DesignspaceGenTestRegular.designspace') + with open(expected_path) as fp: + expected = fp.read() + + assert expected == actual + + +def test_designspace_generation_italic_same_family_name(tmpdir): + ufo_Lt = defcon.Font() + ufo_Lt.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Lt.info.styleName = 'Light Italic' + ufo_Lt.info.openTypeOS2WeightClass = 300 + ufo_Lt.info.italicAngle = -11 + + ufo_Rg = defcon.Font() + ufo_Rg.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Rg.info.styleName = 'Regular Italic' + ufo_Rg.info.openTypeOS2WeightClass = 400 + ufo_Rg.info.italicAngle = -11 + + ufo_Md = defcon.Font() + ufo_Md.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Md.info.styleName = 'Medium Italic' + ufo_Md.info.openTypeOS2WeightClass = 500 + ufo_Md.info.italicAngle = -11 + + ufo_Bd = defcon.Font() + ufo_Bd.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Bd.info.styleName = 'Bold Italic' + ufo_Bd.info.openTypeOS2WeightClass = 700 + ufo_Bd.info.italicAngle = -11 + + ufo_ExBd = defcon.Font() + ufo_ExBd.info.familyName = 'CoolFoundry Examplary Serif' + ufo_ExBd.info.styleName = 'XBold Italic' + ufo_ExBd.info.openTypeOS2WeightClass = 800 + ufo_ExBd.info.italicAngle = -11 + + font = to_glyphs([ufo_Lt, ufo_Rg, ufo_Md, ufo_Bd, ufo_ExBd]) + designspace = to_designspace(font) + + path = os.path.join(str(tmpdir), 'actual.designspace') + designspace.write(path) + with open(path) as fp: + actual = fp.read() + + expected_path = os.path.join(os.path.dirname(__file__), '..', 'data', + 'DesignspaceGenTestItalic.designspace') + with open(expected_path) as fp: + expected = fp.read() + + assert expected == actual + + +def test_designspace_generation_regular_different_family_names(tmpdir): + ufo_Lt = defcon.Font() + ufo_Lt.info.familyName = 'CoolFoundry Examplary Serif Light' + ufo_Lt.info.styleName = 'Regular' + ufo_Lt.info.openTypeOS2WeightClass = 300 + + ufo_Rg = defcon.Font() + ufo_Rg.info.familyName = 'CoolFoundry Examplary Serif' + ufo_Rg.info.styleName = 'Regular' + ufo_Rg.info.openTypeOS2WeightClass = 400 + + # Different family names are not allowed + # REVIEW: reasonable requirement? + with pytest.raises(Exception): + font = to_glyphs([ufo_Lt, ufo_Rg]) diff --git a/tests/builder/features_test.py b/tests/builder/features_test.py new file mode 100644 index 000000000..1b00b7ca4 --- /dev/null +++ b/tests/builder/features_test.py @@ -0,0 +1,278 @@ +# coding=UTF-8 +# +# Copyright 2016 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 +import pytest +from textwrap import dedent + +import defcon + +from glyphsLib import to_glyphs, to_designspace, to_ufos, classes + + +def make_font(features): + ufo = defcon.Font() + ufo.features = dedent(features) + return ufo + + +def roundtrip(ufo, tmpdir): + font = to_glyphs([ufo], minimize_ufo_diffs=True) + filename = os.path.join(str(tmpdir), 'font.glyphs') + font.save(filename) + font = classes.GSFont(filename) + ufo, = to_ufos(font) + return font, ufo + + +def test_blank(tmpdir): + ufo = defcon.Font() + + font, rtufo = roundtrip(ufo, tmpdir) + + assert not font.features + assert not font.featurePrefixes + assert not rtufo.features.text + + +def test_comment(tmpdir): + ufo = defcon.Font() + ufo.features.text = dedent('''\ + # Test + # Lol + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert not font.features + assert len(font.featurePrefixes) == 1 + fp = font.featurePrefixes[0] + assert fp.code.strip() == ufo.features.text.strip() + assert not fp.automatic + + assert rtufo.features.text == ufo.features.text + + +def test_languagesystems(tmpdir): + ufo = defcon.Font() + # The sample has messed-up spacing because there was a problem with that + ufo.features.text = dedent('''\ + # Blah + languagesystem DFLT dflt; #Default + languagesystem latn dflt;\t# Latin + \tlanguagesystem arab URD; #\tUrdu + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert not font.features + assert len(font.featurePrefixes) == 1 + fp = font.featurePrefixes[0] + assert fp.code == ufo.features.text[:-1] # Except newline + assert not fp.automatic + + assert rtufo.features.text == ufo.features.text + + +def test_classes(tmpdir): + ufo = defcon.Font() + # FIXME: (jany) no whitespace is preserved in this section + ufo.features.text = dedent('''\ + @lc = [ a b ]; + + @UC = [ A B ]; + + @all = [ @lc @UC zero one ]; + + @more = [ dot @UC colon @lc paren ]; + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert len(font.classes) == 4 + assert font.classes['lc'].code == 'a b' + assert font.classes['UC'].code == 'A B' + assert font.classes['all'].code == '@lc @UC zero one' + assert font.classes['more'].code == 'dot @UC colon @lc paren' + + assert rtufo.features.text == ufo.features.text + + +def test_class_synonym(tmpdir): + ufo = defcon.Font() + ufo.features.text = dedent('''\ + @lc = [ a b ]; + + @lower = @lc; + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert len(font.classes) == 2 + assert font.classes['lc'].code == 'a b' + assert font.classes['lower'].code == '@lc' + + # FIXME: (jany) should roundtrip + assert rtufo.features.text == dedent('''\ + @lc = [ a b ]; + + @lower = [ @lc ]; + ''') + + +def test_include(tmpdir): + ufo = defcon.Font() + ufo.features.text = dedent('''\ + include(../family.fea); + # Blah + include(../fractions.fea); + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert len(font.featurePrefixes) == 1 + assert font.featurePrefixes[0].code.strip() == ufo.features.text.strip() + + assert rtufo.features.text == ufo.features.text + + +def test_include_no_semicolon(tmpdir): + ufo = defcon.Font() + ufo.features.text = dedent('''\ + include(../family.fea) + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert len(font.featurePrefixes) == 1 + assert font.featurePrefixes[0].code.strip() == ufo.features.text.strip() + + assert rtufo.features.text == ufo.features.text + + +def test_standalone_lookup(tmpdir): + ufo = defcon.Font() + # FIXME: (jany) does not preserve whitespace before and after + ufo.features.text = dedent('''\ + # start of default rules that are applied under all language systems. + lookup HAS_I { + sub f f i by f_f_i; + sub f i by f_i; + } HAS_I; + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert len(font.featurePrefixes) == 1 + assert font.featurePrefixes[0].code.strip() == ufo.features.text.strip() + + assert rtufo.features.text == ufo.features.text + + +def test_feature(tmpdir): + ufo = defcon.Font() + # This sample is straight from the documentation at + # http://www.adobe.com/devnet/opentype/afdko/topic_feature_file_syntax.html + # FIXME: (jany) does not preserve whitespace before and after + ufo.features.text = dedent('''\ + feature liga { + # start of default rules that are applied under all language systems. + lookup HAS_I { + sub f f i by f_f_i; + sub f i by f_i; + } HAS_I; + + lookup NO_I { + sub f f l by f_f_l; + sub f f by f_f; + } NO_I; + + # end of default rules that are applied under all language systems. + + script latn; + language dflt; + # default lookup for latn included under all languages for the latn script + + sub f l by f_l; + language DEU; + # default lookups included under the DEU language.. + sub s s by germandbls; # This is also included. + language TRK exclude_dflt; # default lookups are excluded. + lookup NO_I; #Only this lookup is included under the TRK language + + script cyrl; + language SRB; + sub c t by c_t; # this rule will apply only under script cyrl language SRB. + } liga; + ''') + + font, rtufo = roundtrip(ufo, tmpdir) + + assert len(font.features) == 1 + # Strip "feature liga {} liga;" + code = ufo.features.text.splitlines()[1:-1] + assert font.features[0].code.strip() == '\n'.join(code) + + assert rtufo.features.text.strip() == ufo.features.text.strip() + + +def test_different_features_in_different_UFOS(tmpdir): + # If the input UFOs have different features, Glyphs cannot model the + # differences easily. + # + # TODO: (jany) A complex solution would be to put all the features that we + # find across all the UFOS into the GSFont's features, and then add custom + # parameters "Replace Features" and "Remove features" to the GSFontMasters + # of the UFOs that didn't have the feature originally. + # + # What is done now: if feature files differ between the input UFOs, the + # original text of each UFO's feature is stored in userData, and a single + # GSFeaturePrefix is created just to warn the user that features were not + # imported because of differences. + ufo1 = defcon.Font() + ufo1.features.text = dedent('''\ + include('../family.fea'); + ''') + ufo2 = defcon.Font() + ufo2.features.text = dedent('''\ + include('../family.fea'); + + feature ss03 { + sub c by c.ss03; + } ss03; + ''') + + font = to_glyphs([ufo1, ufo2], minimize_ufo_diffs=True) + filename = os.path.join(str(tmpdir), 'font.glyphs') + font.save(filename) + font = classes.GSFont(filename) + ufo1rt, ufo2rt = to_ufos(font) + + assert len(font.features) == 0 + assert len(font.featurePrefixes) == 1 + assert font.featurePrefixes[0].code == dedent('''\ + # Do not use Glyphs to edit features. + # + # This Glyphs file was made from several UFOs that had different + # features. As a result, the features are not editable in Glyphs and + # the original features will be restored when you go back to UFOs. + ''') + + assert ufo1rt.features.text == ufo1.features.text + assert ufo2rt.features.text == ufo2.features.text diff --git a/tests/builder/fontinfo_test.py b/tests/builder/fontinfo_test.py new file mode 100644 index 000000000..9d5a96b99 --- /dev/null +++ b/tests/builder/fontinfo_test.py @@ -0,0 +1,521 @@ +# coding=UTF-8 +# +# Copyright 2016 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) + + +""" +Goal: have a unit test for each parameter that is mentioned in the UFO spec. +Each test will check that this parameter is round-tripped and, when relevant, +that the Glyphs storage of the value has the correct Glyphs meaning. + +http://unifiedfontobject.org/versions/ufo3/fontinfo.plist/ +""" + +import os +import pytest + +import defcon + +from glyphsLib import to_glyphs, to_designspace, to_ufos, classes + + +def section(name, *fields): + return pytest.param(fields, id=name) + + +def skip_section(name, *fields): + return pytest.param(fields, id=name, marks=pytest.mark.skip) + + +class Field(object): + def __init__(self, name, test_value): + self.name = name + self.test_value = test_value + +ufo_info_spec = [ + section( + 'Generic Identification Information', + # familyName string Family name. + Field('familyName', 'Ronoto Sans'), + + # styleName string Style name. + Field('styleName', 'Condensed'), + + # styleMapFamilyName string Family name used for bold, italic and bold + # italic style mapping. + Field('styleMapFamilyName', 'Ronoto Sans Condensed'), + + # styleMapStyleName string Style map style. The possible values are + # regular, italic, bold and bold italic. These are case sensitive. + Field('styleMapStyleName', 'regular'), + + # versionMajor integer Major version. + Field('versionMajor', 1), + + # versionMinor non-negative integer Minor version. + Field('versionMinor', 12), + + # year integer The year the font was created. This attribute is + # deprecated as of version 2. Its presence should not be relied upon by + # authoring tools. However, it may occur in a font's info so authoring + # tools should preserve it if present. + Field('year', 2013), + ), + section( + 'Generic Legal Information', + # copyright string Copyright statement. + Field('copyright', '© Glooble'), + # trademark string Trademark statement. + Field('trademark', 'Ronoto™® is a trademark of Glooble Inc. Ltd.'), + ), + section( + 'Generic Dimension Information', + # unitsPerEm non-negative integer or float Units per em. + Field('unitsPerEm', 1234), + # descender integer or float Descender value. + Field('descender', 123.7), + # xHeight integer or float x-height value. + Field('xHeight', 456), + # capHeight integer or float Cap height value. + Field('capHeight', 789), + # ascender integer or float Ascender value. + Field('ascender', 789.1), + # italicAngle integer or float Italic angle. This must be an angle in + # counter-clockwise degrees from the vertical. + Field('italicAngle', -12.5), + ), + section( + 'Generic Miscellaneous Information', + # note string Arbitrary note about the font. + Field('note', 'Bla bla'), + ), + section( + 'OpenType GASP Table Fields', + # openTypeGaspRangeRecords list A list of gasp Range Records. These + # must be sorted in ascending order based on the rangeMaxPPEM value of + # the record. + # + # The records are stored as dictionaries of the following format. + # key value type description + # rangeMaxPPEM non-negative integer The upper limit of the range, in + # PPEM. If any records are in the list, the final record should use + # 65535 (0xFFFF) as defined in the OpenType gasp specification. + # Corresponds to the rangeMaxPPEM field of the GASPRANGE record in the + # OpenType gasp table. + # rangeGaspBehavior list A list of bit numbers indicating the flags + # to be set. The bit numbers are defined below. Corresponds to the + # rangeGaspBehavior field of the GASPRANGE record in the OpenType gasp + # table. + Field('openTypeGaspRangeRecords', [ + { + 'rangeMaxPPEM': 16, + 'rangeGaspBehavior': [0], + }, + { + 'rangeMaxPPEM': 65535, + 'rangeGaspBehavior': [0, 1], + } + ]), + ), + section( + 'OpenType head Table Fields', + # openTypeHeadCreated string Creation date. Expressed as a string of + # the format “YYYY/MM/DD HH:MM:SS”. “YYYY/MM/DD” is year/month/day. The + # month must be in the range 1-12 and the day must be in the range + # 1-end of month. “HH:MM:SS” is hour:minute:second. The hour must be in + # the range 0:23. The minute and second must each be in the range 0-59. + Field('openTypeHeadCreated', '2014/02/28 19:20:48'), + # openTypeHeadLowestRecPPEM non-negative integer Smallest readable size + # in pixels. Corresponds to the OpenType head table lowestRecPPEM + # field. + Field('openTypeHeadLowestRecPPEM', 12), + # openTypeHeadFlags list A list of bit numbers indicating the flags. + # The bit numbers are listed in the OpenType head specification. + # Corresponds to the OpenType head table flags field. + Field('openTypeHeadFlags', [2, 3, 11]), + ), + section( + 'OpenType hhea Table Fields', + # openTypeHheaAscender integer Ascender value. Corresponds to the + # OpenType hhea table Ascender field. + Field('openTypeHheaAscender', 123), + # openTypeHheaDescender integer Descender value. Corresponds to the + # OpenType hhea table Descender field. + Field('openTypeHheaDescender', 456), + # openTypeHheaLineGap integer Line gap value. Corresponds to the + # OpenType hhea table LineGap field. + Field('openTypeHheaLineGap', 789), + # openTypeHheaCaretSlopeRise integer Caret slope rise value. + # Corresponds to the OpenType hhea table caretSlopeRise field. + Field('openTypeHheaCaretSlopeRise', 800), + # openTypeHheaCaretSlopeRun integer Caret slope run value. Corresponds + # to the OpenType hhea table caretSlopeRun field. + Field('openTypeHheaCaretSlopeRun', 100), + # openTypeHheaCaretOffset integer Caret offset value. Corresponds to + # the OpenType hhea table caretOffset field. + Field('openTypeHheaCaretOffset', 20), + ), + section( + 'OpenType name Table Fields', + # openTypeNameDesigner string Designer name. + # Corresponds to the OpenType name table name ID 9. + Field('openTypeNameDesigner', 'Bob'), + # openTypeNameDesignerURL string URL for the designer. + # Corresponds to the OpenType name table name ID 12. + Field('openTypeNameDesignerURL', 'http://bob.me/'), + # openTypeNameManufacturer string Manufacturer name. + # Corresponds to the OpenType name table name ID 8. + Field('openTypeNameManufacturer', 'Exemplary Type'), + # openTypeNameManufacturerURL string Manufacturer URL. + # Corresponds to the OpenType name table name ID 11. + Field('openTypeNameManufacturerURL', 'http://exemplary.type'), + # openTypeNameLicense string License text. + # Corresponds to the OpenType name table name ID 13. + Field('openTypeNameLicense', 'OFL 1.1'), + # openTypeNameLicenseURL string URL for the license. + # Corresponds to the OpenType name table name ID 14. + Field('openTypeNameLicenseURL', 'http://scripts.sil.org/OFL'), + # openTypeNameVersion string Version string. + # Corresponds to the OpenType name table name ID 5. + Field('openTypeNameVersion', 'Version 2.003'), + # openTypeNameUniqueID string Unique ID string. + # Corresponds to the OpenType name table name ID 3. + Field('openTypeNameUniqueID', '2.003;Exemplary Sans Bold Large Display'), + # openTypeNameDescription string Description of the font. + # Corresponds to the OpenType name table name ID 10. + Field('openTypeNameDescription', 'Best used\nfor typesetting\nhaikus'), + # openTypeNamePreferredFamilyName string Preferred family name. + # Corresponds to the OpenType name table name ID 16. + Field('openTypeNamePreferredFamilyName', 'Exemplary Sans'), + # openTypeNamePreferredSubfamilyName string Preferred subfamily name. + # Corresponds to the OpenType name table name ID 17. + Field('openTypeNamePreferredSubfamilyName', 'Bold Large Display'), + # openTypeNameCompatibleFullName string Compatible full name. + # Corresponds to the OpenType name table name ID 18. + Field('openTypeNameCompatibleFullName', 'Exemplary Sans Bold Large Display'), + # openTypeNameSampleText string Sample text. + # Corresponds to the OpenType name table name ID 19. + Field('openTypeNameSampleText', 'Pickles are our friends'), + # openTypeNameWWSFamilyName string WWS family name. + # Corresponds to the OpenType name table name ID 21. + Field('openTypeNameWWSFamilyName', 'Exemplary Sans Display'), + # openTypeNameWWSSubfamilyName string WWS Subfamily name. + # Corresponds to the OpenType name table name ID 22. + Field('openTypeNameWWSSubfamilyName', 'Bold Large'), + # openTypeNameRecords list A list of name records. This name record storage + # area is intended for records that require platform, encoding and or + # language localization. + # The records are stored as dictionaries of the following format. + # nameID non-negative integer The name ID. + # platformID non-negative integer The platform ID. + # encodingID non-negative integer The encoding ID. + # languageID non-negative integer The language ID. + # string string The string value for the record. + Field('openTypeNameRecords', [ + { + 'nameID': 19, + 'platformID': 1, + 'encodingID': 0, + 'languageID': 1, + 'string': 'Les cornichons sont nos amis' + }, + { + 'nameID': 1, + 'platformID': 3, + 'encodingID': 1, + 'languageID': 0x0410, + 'string': 'Illustrativo Sans' + }, + ]), + ), + section( + 'OpenType OS/2 Table Fields', + # openTypeOS2WidthClass integer Width class value. Must be in the range + # 1-9 Corresponds to the OpenType OS/2 table usWidthClass field. + Field('openTypeOS2WidthClass', 7), + # openTypeOS2WeightClass integer Weight class value. Must be a + # non-negative integer. Corresponds to the OpenType OS/2 table + # usWeightClass field. + Field('openTypeOS2WeightClass', 700), + # openTypeOS2Selection list A list of bit numbers indicating the bits + # that should be set in fsSelection. The bit numbers are listed in the + # OpenType OS/2 specification. + # Corresponds to the OpenType OS/2 table selection field. + # Note: Bits 0 (italic), 5 (bold) and 6 (regular) must not be set here. + # These bits should be taken from the generic styleMapStyle attribute. + Field('openTypeOS2Selection', [8, 7]), + # openTypeOS2VendorID string Four character identifier for the creator + # of the font. Corresponds to the OpenType OS/2 table achVendID field. + Field('openTypeOS2VendorID', 'EXTY'), + # openTypeOS2Panose list The list must contain 10 non-negative integers + # that represent the setting for each category in the Panose + # specification. The integers correspond with the option numbers in + # each of the Panose categories. This corresponds to the OpenType OS/2 + # table Panose field. + Field('openTypeOS2Panose', [2, 11, 8, 5, 3, 4, 0, 0, 0, 0]), + # openTypeOS2FamilyClass list Two integers representing the IBM font + # class and font subclass of the font. The first number, representing + # the class ID, must be in the range 0-14. The second number, + # representing the subclass, must be in the range 0-15. The numbers are + # listed in the OpenType OS/2 specification. + # Corresponds to the OpenType OS/2 table sFamilyClass field. + Field('openTypeOS2FamilyClass', [8, 2]), + # openTypeOS2UnicodeRanges list A list of bit numbers that are + # supported Unicode ranges in the font. The bit numbers are listed in + # the OpenType OS/2 specification. Corresponds to the OpenType OS/2 + # table ulUnicodeRange1, ulUnicodeRange2, ulUnicodeRange3 and + # ulUnicodeRange4 fields. + Field('openTypeOS2UnicodeRanges', [0, 1, 2, 3, 37, 79, 122]), + # openTypeOS2CodePageRanges list A list of bit numbers that are + # supported code page ranges in the font. The bit numbers are listed in + # the OpenType OS/2 specification. Corresponds to the OpenType OS/2 + # table ulCodePageRange1 and ulCodePageRange2 fields. + Field('openTypeOS2CodePageRanges', [0, 1, 29, 58]), + # openTypeOS2TypoAscender integer Ascender value. + # Corresponds to the OpenType OS/2 table sTypoAscender field. + Field('openTypeOS2TypoAscender', 1000), + # openTypeOS2TypoDescender integer Descender value. + # Corresponds to the OpenType OS/2 table sTypoDescender field. + Field('openTypeOS2TypoDescender', -234), + # openTypeOS2TypoLineGap integer Line gap value. + # Corresponds to the OpenType OS/2 table sTypoLineGap field. + Field('openTypeOS2TypoLineGap', 456), + # openTypeOS2WinAscent non-negative integer Ascender value. + # Corresponds to the OpenType OS/2 table usWinAscent field. + Field('openTypeOS2WinAscent', 1500), + # openTypeOS2WinDescent non-negative integer Descender value. + # Corresponds to the OpenType OS/2 table usWinDescent field. + Field('openTypeOS2WinDescent', 750), + # openTypeOS2Type list A list of bit numbers indicating the embedding + # type. The bit numbers are listed in the OpenType OS/2 specification. + # Corresponds to the OpenType OS/2 table fsType field. + Field('openTypeOS2Type', [3, 8]), + # openTypeOS2SubscriptXSize integer Subscript horizontal font size. + # Corresponds to the OpenType OS/2 table ySubscriptXSize field. + Field('openTypeOS2SubscriptXSize', 123), + # openTypeOS2SubscriptYSize integer Subscript vertical font size. + # Corresponds to the OpenType OS/2 table ySubscriptYSize field. + Field('openTypeOS2SubscriptYSize', 246), + # openTypeOS2SubscriptXOffset integer Subscript x offset. + # Corresponds to the OpenType OS/2 table ySubscriptXOffset field. + Field('openTypeOS2SubscriptXOffset', -6), + # openTypeOS2SubscriptYOffset integer Subscript y offset. + # Corresponds to the OpenType OS/2 table ySubscriptYOffset field. + Field('openTypeOS2SubscriptYOffset', 100), + # openTypeOS2SuperscriptXSize integer Superscript horizontal font size. + # Corresponds to the OpenType OS/2 table ySuperscriptXSize field. + Field('openTypeOS2SuperscriptXSize', 124), + # openTypeOS2SuperscriptYSize integer Superscript vertical font size. + # Corresponds to the OpenType OS/2 table ySuperscriptYSize field. + Field('openTypeOS2SuperscriptYSize', 248), + # openTypeOS2SuperscriptXOffset integer Superscript x offset. + # Corresponds to the OpenType OS/2 table ySuperscriptXOffset field. + Field('openTypeOS2SuperscriptXOffset', -8), + # openTypeOS2SuperscriptYOffset integer Superscript y offset. + # Corresponds to the OpenType OS/2 table ySuperscriptYOffset field. + Field('openTypeOS2SuperscriptYOffset', 400), + # openTypeOS2StrikeoutSize integer Strikeout size. + # Corresponds to the OpenType OS/2 table yStrikeoutSize field. + Field('openTypeOS2StrikeoutSize', 56), + # openTypeOS2StrikeoutPosition integer Strikeout position. + # Corresponds to the OpenType OS/2 table yStrikeoutPosition field. + Field('openTypeOS2StrikeoutPosition', 200), + ), + section( + 'OpenType vhea Table Fields', + # openTypeVheaVertTypoAscender integer Ascender value. Corresponds to + # the OpenType vhea table vertTypoAscender field. + Field('openTypeVheaVertTypoAscender', 123), + # openTypeVheaVertTypoDescender integer Descender value. Corresponds to + # the OpenType vhea table vertTypoDescender field. + Field('openTypeVheaVertTypoDescender', 456), + # openTypeVheaVertTypoLineGap integer Line gap value. Corresponds to + # the OpenType vhea table vertTypoLineGap field. + Field('openTypeVheaVertTypoLineGap', 789), + # openTypeVheaCaretSlopeRise integer Caret slope rise value. + # Corresponds to the OpenType vhea table caretSlopeRise field. + Field('openTypeVheaCaretSlopeRise', 23), + # openTypeVheaCaretSlopeRun integer Caret slope run value. Corresponds + # to the OpenType vhea table caretSlopeRun field. + Field('openTypeVheaCaretSlopeRun', 1024), + # openTypeVheaCaretOffset integer Caret offset value. Corresponds to + # the OpenType vhea table caretOffset field. + Field('openTypeVheaCaretOffset', 50), + ), + section( + 'PostScript Specific Data', + # postscriptFontName string Name to be used for the FontName field in + # Type 1/CFF table. + Field('postscriptFontName', 'Exemplary-Sans'), + # postscriptFullName string Name to be used for the FullName field in + # Type 1/CFF table. + Field('postscriptFullName', 'Exemplary Sans Bold Whatever'), + # postscriptSlantAngle integer or float Artificial slant angle. This + # must be an angle in counter-clockwise degrees from the vertical. + Field('postscriptSlantAngle', -15.5), + # postscriptUniqueID integer A unique ID number as defined in the Type + # 1/CFF specification. + Field('postscriptUniqueID', 123456789), + # postscriptUnderlineThickness integer or float Underline thickness + # value. Corresponds to the Type 1/CFF/post table UnderlineThickness + # field. + Field('postscriptUnderlineThickness', 70), + # postscriptUnderlinePosition integer or float Underline position + # value. Corresponds to the Type 1/CFF/post table UnderlinePosition + # field. + Field('postscriptUnderlinePosition', -50), + # postscriptIsFixedPitch boolean Indicates if the font is monospaced. + # An authoring tool could calculate this automatically, but the + # designer may wish to override this setting. This corresponds to the + # Type 1/CFF isFixedPitched field + Field('postscriptIsFixedPitch', True), + # postscriptBlueValues list A list of up to 14 integers or floats + # specifying the values that should be in the Type 1/CFF BlueValues + # field. This list must contain an even number of integers following + # the rules defined in the Type 1/CFF specification. + Field('postscriptBlueValues', [-200, -185, -15, 0, 500, 515]), + # postscriptOtherBlues list A list of up to 10 integers or floats + # specifying the values that should be in the Type 1/CFF OtherBlues + # field. This list must contain an even number of integers following + # the rules defined in the Type 1/CFF specification. + Field('postscriptOtherBlues', [-315, -300, 385, 400]), + # postscriptFamilyBlues list A list of up to 14 integers or floats + # specifying the values that should be in the Type 1/CFF FamilyBlues + # field. This list must contain an even number of integers following + # the rules defined in the Type 1/CFF specification. + Field('postscriptFamilyBlues', [-210, -195, -25, 0, 510, 525]), + # postscriptFamilyOtherBlues list A list of up to 10 integers or floats + # specifying the values that should be in the Type 1/CFF + # FamilyOtherBlues field. This list must contain an even number of + # integers following the rules defined in the Type 1/CFF specification. + Field('postscriptFamilyOtherBlues', [-335, -330, 365, 480]), + # postscriptStemSnapH list List of horizontal stems sorted in the order + # specified in the Type 1/CFF specification. Up to 12 integers or + # floats are possible. This corresponds to the Type 1/CFF StemSnapH + # field. + Field('postscriptStemSnapH', [-10, 40, 400, 789]), + # postscriptStemSnapV list List of vertical stems sorted in the order + # specified in the Type 1/CFF specification. Up to 12 integers or + # floats are possible. This corresponds to the Type 1/CFF StemSnapV + # field. + Field('postscriptStemSnapV', [-500, -40, 0, 390, 789]), + # postscriptBlueFuzz integer or float BlueFuzz value. This corresponds + # to the Type 1/CFF BlueFuzz field. + Field('postscriptBlueFuzz', 2), + # postscriptBlueShift integer or float BlueShift value. This + # corresponds to the Type 1/CFF BlueShift field. + Field('postscriptBlueShift', 10), + # postscriptBlueScale float BlueScale value. This corresponds to the + # Type 1/CFF BlueScale field. + Field('postscriptBlueScale', 0.0256), + # postscriptForceBold boolean Indicates how the Type 1/CFF ForceBold + # field should be set. + Field('postscriptForceBold', True), + # postscriptDefaultWidthX integer or float Default width for glyphs. + Field('postscriptDefaultWidthX', 250), + # postscriptNominalWidthX integer or float Nominal width for glyphs. + Field('postscriptNominalWidthX', 10), + # postscriptWeightName string A string indicating the overall weight of + # the font. This corresponds to the Type 1/CFF Weight field. It should + # be in sync with the openTypeOS2WeightClass value. + Field('postscriptWeightName', 'Bold'), + # postscriptDefaultCharacter string The name of the glyph that should + # be used as the default character in PFM files. + Field('postscriptDefaultCharacter', 'a'), + # postscriptWindowsCharacterSet integer The Windows character set. The + # values are defined below. + Field('postscriptWindowsCharacterSet', 4), + ), + section( + 'Macintosh FOND Resource Data', + # macintoshFONDFamilyID integer Family ID number. Corresponds to the + # ffFamID in the FOND resource. + Field('macintoshFONDFamilyID', 12345), + # macintoshFONDName string Font name for the FOND resource. + Field('macintoshFONDName', 'ExemplarySansBold'), + ), + skip_section( + 'WOFF Data', + # woffMajorVersion non-negative integer Major version of the font. + Field('woffMajorVersion', 1), + # woffMinorVersion non-negative integer Minor version of the font. + Field('woffMinorVersion', 12), + # woffMetadataUniqueID dictionary Identification string. Corresponds to + # the WOFF uniqueid. The dictionary must follow the WOFF Metadata + # Unique ID Record structure. + Field('woffMetadataUniqueID', { + }), + # woffMetadataVendor dictionary Font vendor. Corresponds to the WOFF + # vendor element. The dictionary must follow the the WOFF Metadata + # Vendor Record structure. + Field('woffMetadataVendor', { + }), + # woffMetadataCredits dictionary Font credits. Corresponds to the WOFF + # credits element. The dictionary must follow the WOFF Metadata Credits + # Record structure. + Field('woffMetadataCredits', { + }), + # woffMetadataDescription dictionary Font description. Corresponds to + # the WOFF description element. The dictionary must follow the WOFF + # Metadata Description Record structure. + Field('woffMetadataDescription', { + }), + # woffMetadataLicense dictionary Font description. Corresponds to the + # WOFF license element. The dictionary must follow the WOFF Metadata + # License Record structure. + Field('woffMetadataLicense', { + }), + # woffMetadataCopyright dictionary Font copyright. Corresponds to the + # WOFF copyright element. The dictionary must follow the WOFF Metadata + # Copyright Record structure. + Field('woffMetadataCopyright', { + }), + # woffMetadataTrademark dictionary Font trademark. Corresponds to the + # WOFF trademark element. The dictionary must follow the WOFF Metadata + # Trademark Record structure. + Field('woffMetadataTrademark', { + }), + # woffMetadataLicensee dictionary Font licensee. Corresponds to the + # WOFF licensee element. The dictionary must follow the WOFF Metadata + # Licensee Record structure. + Field('woffMetadataLicensee', { + }), + # woffMetadataExtensions list List of metadata extension records. The + # dictionaries must follow the WOFF Metadata Extension Record + # structure. There must be at least one extension record in the list. + Field('woffMetadataExtensions', { + }), + ), +] + + +@pytest.mark.parametrize('fields', ufo_info_spec) +def test_info(fields, tmpdir): + ufo = defcon.Font() + + for field in fields: + setattr(ufo.info, field.name, field.test_value) + + font = to_glyphs([ufo], minimize_ufo_diffs=True) + filename = os.path.join(str(tmpdir), 'font.glyphs') + font.save(filename) + font = classes.GSFont(filename) + ufo, = to_ufos(font) + + for field in fields: + assert getattr(ufo.info, field.name) == field.test_value diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py new file mode 100644 index 000000000..957aed333 --- /dev/null +++ b/tests/builder/interpolation_test.py @@ -0,0 +1,554 @@ +# 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.instances import set_weight_class, set_width_class +from glyphsLib.classes import GSFont, GSFontMaster, GSInstance +from glyphsLib import to_designspace, to_glyphs, build_instances +from glyphsLib.builder.constants import UFO2FT_USE_PROD_NAMES_KEY + + +# 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 makeInstanceDescriptor(*args, **kwargs): + """Same as makeInstance but return the corresponding InstanceDescriptor.""" + ginst = makeInstance(*args, **kwargs) + font = makeFont([makeMaster('Regular')], [ginst], 'Family') + doc = to_designspace(font) + return doc, doc.instances[0] + + +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) + + # Although inactive instances are not exported by default, + # all instances are exported when intending to roundtrip Glyphs->Glyphs + doc = to_designspace(font, minimize_glyphs_diffs=True) + self.assertEqual(4, len(doc.instances)) + + 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_instance_filtering_by_family_name(self): + # See https://github.com/googlei18n/fontmake/issues/257 + path = os.path.join(os.path.dirname(__file__), '..', 'data', + 'MontserratStrippedDown.glyphs') + font = GSFont(path) + + # By default (no special parameter), all instances are exported + designspace_all = to_designspace(font) + assert len(designspace_all.instances) == 18 + + # If we specify that we want the same familyName as the masters, + # we only get instances that have that same family name, and the + # masters are copied as-is. (basically a subset of the previous doc) + designspace_no_alternates = to_designspace( + font, family_name='Montserrat') + assert len(designspace_no_alternates.instances) == 9 + + # If we specify the alternate family name, we only get the instances + # that have that family name, and the masters are renamed to have the + # given family name. + designspace_alternates = to_designspace( + font, family_name='Montserrat Alternates') + assert (designspace_alternates.sources[0].familyName == + 'Montserrat Alternates') + assert (designspace_alternates.sources[0].font.info.familyName == + 'Montserrat Alternates') + assert len(designspace_alternates.instances) == 9 + + +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 + doc, instance = makeInstanceDescriptor("Bold") + set_weight_class(ufo, doc, instance) + # 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 + # FIXME: (jany) why do we want to write something Glyphs-specific in + # the instance UFO? Does someone later in the toolchain rely on it? + # self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") + + def test_weight_class(self): + ufo = defcon.Font() + doc, data = makeInstanceDescriptor( + "Bold", + weight=("Bold", None, 150) + ) + + set_weight_class(ufo, doc, data) + + self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) + # self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") + + def test_explicit_default_weight(self): + ufo = defcon.Font() + doc, data = makeInstanceDescriptor( + "Regular", + weight=("Regular", None, 100) + ) + + set_weight_class(ufo, doc, 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 + doc, data = makeInstanceDescriptor("Normal") + set_width_class(ufo, doc, 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_width_class(self): + ufo = defcon.Font() + doc, data = makeInstanceDescriptor( + "Condensed", + width=("Condensed", 3, 80) + ) + + set_width_class(ufo, doc, data) + + self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) + # self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") + + def test_explicit_default_width(self): + ufo = defcon.Font() + doc, data = makeInstanceDescriptor( + "Regular", + width=("Medium (normal)", 5, 100) + ) + + set_width_class(ufo, doc, 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() + doc, data = makeInstanceDescriptor( + "SemiCondensed ExtraBold", + weight=("ExtraBold", None, 160), + width=("SemiCondensed", 4, 90) + ) + + set_weight_class(ufo, doc, data) + set_width_class(ufo, doc, 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_ui_string_but_defined_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. + doc, data = makeInstanceDescriptor( + "DemiLight Italic", + weight=("DemiLight", 350, 70) + ) + + set_weight_class(ufo, doc, data) + + # Here we have set the weightClass to 350 so even though the string + # is wrong, our value of 350 should be used. + self.assertTrue(ufo.info.openTypeOS2WeightClass == 350) + + 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. + doc, data = makeInstanceDescriptor( + "DemiLight Italic", + weight=("DemiLight", None, 70) + ) + + set_weight_class(ufo, doc, data) + + # we do not set any OS/2 weight class; user needs to provide + # a 'weightClass' custom parameter in this special case + # FIXME: (jany) the new code writes the default OS2 class instead of + # None. Is that a problem? + self.assertTrue(ufo.info.openTypeOS2WeightClass == 400) + + +def test_apply_instance_data(tmpdir): + # Goal: test that the existing APIs used by fontmake and possibly others + # still work (especially `apply_instance_data`) + masters, instances = makeFamily() + gasp_table = {'65535': '15', '20': '7', '8': '10'} + instances[0].customParameters['GASP Table'] = gasp_table + instances[1].customParameters["Don't use Production Names"] = True + font = makeFont(masters, instances, 'Exemplary Sans') + filename = os.path.join(str(tmpdir), 'font.glyphs') + font.save(filename) + + master_dir = os.path.join(str(tmpdir), 'master_ufos_test') + os.mkdir(master_dir) + instance_dir = os.path.join(str(tmpdir), 'instance_ufos_test') + os.mkdir(instance_dir) + ufos = build_instances(filename, master_dir, instance_dir) + + ufo_range_records = ufos[0].info.openTypeGaspRangeRecords + assert ufo_range_records is not None + assert len(ufo_range_records) == 3 + + assert UFO2FT_USE_PROD_NAMES_KEY in ufos[1].lib + assert ufos[1].lib[UFO2FT_USE_PROD_NAMES_KEY] == False + + +if __name__ == "__main__": + sys.exit(unittest.main()) diff --git a/tests/builder/lib_and_user_data.png b/tests/builder/lib_and_user_data.png new file mode 100644 index 000000000..7e7b40e90 Binary files /dev/null and b/tests/builder/lib_and_user_data.png differ diff --git a/tests/builder/lib_and_user_data.uml b/tests/builder/lib_and_user_data.uml new file mode 100644 index 000000000..618a012e2 --- /dev/null +++ b/tests/builder/lib_and_user_data.uml @@ -0,0 +1,96 @@ +@startuml + +title + Relationships between the various Designspace/UFO ""lib"" fields + and their Glyphs.app ""userData"" counterparts + +end title + +skinParam { + ClassArrowThickness 2 +} + +package Designspace { + class DesignSpaceDocument { + + lib + } +} + +package UFO { + class Font { + + lib + + data + } + DesignSpaceDocument o-- "*" Font + + class Layer { + + lib + } + Font *-- "*" Layer + + class Glyph { + + lib + } + Layer *-- "*" Glyph +} + +package Glyphs.app { + class GSFont { + + userData + } + + class GSFontMaster { + + userData + } + GSFont *-- "*" GSFontMaster + + class GSGlyph { + + userData + } + GSFont *--- "*" GSGlyph + + class GSLayer { + + userData + } + GSGlyph *-- "*" GSLayer + + class GSNode { + + userData + } + GSLayer *-- "*" GSNode +} + + +DesignSpaceDocument "1" <-[#green]> "1" GSFont +note on link + Green arrows represent a 1-to-1 + mapping between Glyphs.app and + UFO/Designspace. In those cases, + the ""lib"" keys will be copied as-is + into ""userData"" and reciprocally. +end note + +Font "1" <-[#green]> "1" GSFontMaster +note on link + The UFO ""data"" will be stored + under a special key in the + masters' ""userData"". +end note + +Layer "*" .up[#blue].> "1" GSFontMaster +Font "1" <.[#blue]. "*" GSGlyph + +Glyph "1" <-[#green]> "1" GSLayer + +Glyph "1" <.[#blue]. "*" GSNode +note bottom on link + Blue arrows mean that there is no + 1-to-1 relationship between the two + worlds, so we store one side into a + special key on the other side. + + Here the GSNode ""userData"" is + stored into a special GLIF ""lib"" key. +end note + +@enduml diff --git a/tests/builder/lib_and_user_data_test.py b/tests/builder/lib_and_user_data_test.py new file mode 100644 index 000000000..50497ec65 --- /dev/null +++ b/tests/builder/lib_and_user_data_test.py @@ -0,0 +1,286 @@ +# 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 base64 +import os +import pytest +from collections import OrderedDict + +import defcon +from fontTools.designspaceLib import DesignSpaceDocument +from glyphsLib import classes +from glyphsLib.builder.constants import GLYPHLIB_PREFIX + +from glyphsLib import to_glyphs, to_ufos, to_designspace + + +# GOAL: Test the translations between the various UFO lib and Glyphs userData. +# See the associated UML diagram: `lib_and_user_data.png` + + +def test_designspace_lib_equivalent_to_font_user_data(tmpdir): + designspace = DesignSpaceDocument() + designspace.lib['designspaceLibKey1'] = 'designspaceLibValue1' + + # Save to disk and reload the designspace to test the write/read of lib + path = os.path.join(str(tmpdir), 'test.designspace') + designspace.write(path) + designspace = DesignSpaceDocument() + designspace.read(path) + + font = to_glyphs(designspace) + + assert font.userData['designspaceLibKey1'] == 'designspaceLibValue1' + + designspace = to_designspace(font) + + assert designspace.lib['designspaceLibKey1'] == 'designspaceLibValue1' + + +def test_font_user_data_to_ufo_lib(): + # This happens only when not building a designspace + # Since there is no designspace.lib to store the font userData, + # the latter is duplicated in each output ufo + font = classes.GSFont() + font.masters.append(classes.GSFontMaster()) + font.masters.append(classes.GSFontMaster()) + font.userData['fontUserDataKey'] = 'fontUserDataValue' + + ufo1, ufo2 = to_ufos(font) + + assert ufo1.lib[GLYPHLIB_PREFIX + 'fontUserData'] == { + 'fontUserDataKey': 'fontUserDataValue' + } + assert ufo2.lib[GLYPHLIB_PREFIX + 'fontUserData'] == { + 'fontUserDataKey': 'fontUserDataValue' + } + + font = to_glyphs([ufo1, ufo2]) + + assert font.userData['fontUserDataKey'] == 'fontUserDataValue' + + +def test_ufo_lib_equivalent_to_font_master_user_data(): + ufo1 = defcon.Font() + ufo1.lib['ufoLibKey1'] = 'ufoLibValue1' + ufo2 = defcon.Font() + ufo2.lib['ufoLibKey2'] = 'ufoLibValue2' + + font = to_glyphs([ufo1, ufo2]) + + assert font.masters[0].userData['ufoLibKey1'] == 'ufoLibValue1' + assert font.masters[1].userData['ufoLibKey2'] == 'ufoLibValue2' + + ufo1, ufo2 = to_ufos(font) + + assert ufo1.lib['ufoLibKey1'] == 'ufoLibValue1' + assert ufo2.lib['ufoLibKey2'] == 'ufoLibValue2' + assert 'ufoLibKey2' not in ufo1.lib + assert 'ufoLibKey1' not in ufo2.lib + + +def test_ufo_data_into_font_master_user_data(tmpdir): + filename = os.path.join('org.customTool', 'ufoData.bin') + data = b'\x00\x01\xFF' + ufo = defcon.Font() + ufo.data[filename] = data + + font = to_glyphs([ufo]) + # Round-trip to disk for this one because I'm not sure there are other + # tests that read-write binary data + path = os.path.join(str(tmpdir), 'font.glyphs') + font.save(path) + font = classes.GSFont(path) + + # The path in the glyphs file should be os-agnostic (forward slashes) + assert font.masters[0].userData[GLYPHLIB_PREFIX + 'ufoData'] == { + # `decode`: not bytes in userData, only strings + 'org.customTool/ufoData.bin': base64.b64encode(data).decode() + } + + ufo, = to_ufos(font) + + assert ufo.data[filename] == data + + +def test_layer_lib_into_font_user_data(): + ufo = defcon.Font() + ufo.layers['public.default'].lib['layerLibKey1'] = 'layerLibValue1' + layer = ufo.newLayer('sketches') + layer.lib['layerLibKey2'] = 'layerLibValue2' + # layers won't roundtrip if they contain no glyph, except for the default + layer.newGlyph('bob') + + font = to_glyphs([ufo]) + + assert font.userData[GLYPHLIB_PREFIX + 'layerLib.public.default'] == { + 'layerLibKey1': 'layerLibValue1' + } + assert font.userData[GLYPHLIB_PREFIX + 'layerLib.sketches'] == { + 'layerLibKey2': 'layerLibValue2' + } + + ufo, = to_ufos(font) + + assert ufo.layers['public.default'].lib['layerLibKey1'] == 'layerLibValue1' + assert 'layerLibKey1' not in ufo.layers['sketches'].lib + assert ufo.layers['sketches'].lib['layerLibKey2'] == 'layerLibValue2' + assert 'layerLibKey2' not in ufo.layers['public.default'].lib + + +def test_glyph_user_data_into_ufo_lib(): + font = classes.GSFont() + font.masters.append(classes.GSFontMaster()) + glyph = classes.GSGlyph('a') + glyph.userData['glyphUserDataKey'] = 'glyphUserDataValue' + font.glyphs.append(glyph) + layer = classes.GSLayer() + layer.layerId = font.masters[0].id + glyph.layers.append(layer) + + ufo, = to_ufos(font) + + assert ufo.lib[GLYPHLIB_PREFIX + 'glyphUserData.a'] == { + 'glyphUserDataKey': 'glyphUserDataValue' + } + + font = to_glyphs([ufo]) + + assert font.glyphs['a'].userData[ + 'glyphUserDataKey'] == 'glyphUserDataValue' + + +def test_glif_lib_equivalent_to_layer_user_data(): + ufo = defcon.Font() + # This glyph is in the `public.default` layer + a = ufo.newGlyph('a') + a.lib['glifLibKeyA'] = 'glifLibValueA' + customLayer = ufo.newLayer('middleground') + # "a" is in both layers + customLayer.newGlyph('a') + # "b" is only in the second layer + b = customLayer.newGlyph('b') + b.lib['glifLibKeyB'] = 'glifLibValueB' + + font = to_glyphs([ufo]) + + for layer_id in font.glyphs['a'].layers.keys(): + layer = font.glyphs['a'].layers[layer_id] + if layer.layerId == font.masters[0].id: + default_layer = layer + else: + middleground = layer + assert default_layer.userData['glifLibKeyA'] == 'glifLibValueA' + assert 'glifLibKeyA' not in middleground.userData.keys() + + for layer_id in font.glyphs['b'].layers.keys(): + layer = font.glyphs['b'].layers[layer_id] + if layer.layerId == font.masters[0].id: + default_layer = layer + else: + middleground = layer + assert 'glifLibKeyB' not in default_layer.userData.keys() + assert middleground.userData['glifLibKeyB'] == 'glifLibValueB' + + ufo, = to_ufos(font) + + assert ufo['a'].lib['glifLibKeyA'] == 'glifLibValueA' + assert 'glifLibKeyA' not in ufo.layers['middleground']['a'] + assert ufo.layers['middleground']['b'].lib[ + 'glifLibKeyB'] == 'glifLibValueB' + + +def test_node_user_data_into_glif_lib(): + font = classes.GSFont() + master = classes.GSFontMaster() + master.id = "M1" + font.masters.append(master) + glyph = classes.GSGlyph('a') + layer = classes.GSLayer() + layer.layerId = "M1" + layer.associatedMasterId = "M1" + glyph.layers.append(layer) + font.glyphs.append(glyph) + path = classes.GSPath() + layer.paths.append(path) + node1 = classes.GSNode() + node1.userData['nodeUserDataKey1'] = 'nodeUserDataValue1' + node2 = classes.GSNode() + node2.userData['nodeUserDataKey2'] = 'nodeUserDataValue2' + path.nodes.append(classes.GSNode()) + path.nodes.append(node1) + path.nodes.append(classes.GSNode()) + path.nodes.append(classes.GSNode()) + path.nodes.append(node2) + + ufo, = to_ufos(font, minimize_glyphs_diffs=True) + + assert ufo['a'].lib[ + GLYPHLIB_PREFIX + 'nodeUserData.0.1'] == { + 'nodeUserDataKey1': 'nodeUserDataValue1' + } + assert ufo['a'].lib[ + GLYPHLIB_PREFIX + 'nodeUserData.0.4'] == { + 'nodeUserDataKey2': 'nodeUserDataValue2' + } + + font = to_glyphs([ufo]) + + path = font.glyphs['a'].layers['M1'].paths[0] + assert path.nodes[1].userData['nodeUserDataKey1'] == 'nodeUserDataValue1' + assert path.nodes[4].userData['nodeUserDataKey2'] == 'nodeUserDataValue2' + + +def test_lib_data_types(tmpdir): + # Test the roundtrip of a few basic types both at the top level and in a + # nested object. + data = OrderedDict({ + 'boolean': True, + 'smooth': False, + 'integer': 1, + 'float': 0.5, + 'array': [], + 'dict': {}, + }) + ufo = defcon.Font() + a = ufo.newGlyph('a') + for key, value in data.items(): + a.lib[key] = value + a.lib['nestedDict'] = dict(data) + a.lib['nestedArray'] = list(data.values()) + a.lib['crazyNesting'] = [{'a': [{'b': [dict(data)]}]}] + + font = to_glyphs([ufo]) + + # FIXME: This test will stop working if the font is written and read back, + # because the file format of Glyphs does not make a difference between + # `True` (bool) and `1` (int). + # filename = os.path.join(str(tmpdir), 'font.glyphs') + # font.save(filename) + # font = classes.GSFont(filename) + + ufo, = to_ufos(font) + + for index, (key, value) in enumerate(data.items()): + assert value == ufo['a'].lib[key] + assert value == ufo['a'].lib['nestedDict'][key] + assert value == ufo['a'].lib['nestedArray'][index] + assert value == ufo['a'].lib['crazyNesting'][0]['a'][0]['b'][0][key] + assert type(value) == type(ufo['a'].lib[key]) + assert type(value) == type(ufo['a'].lib['nestedDict'][key]) + assert type(value) == type(ufo['a'].lib['nestedArray'][index]) + assert type(value) == type(ufo['a'].lib['crazyNesting'][0]['a'][0]['b'][0][key]) diff --git a/tests/roundtrip_test.py b/tests/builder/roundtrip_test.py similarity index 92% rename from tests/roundtrip_test.py rename to tests/builder/roundtrip_test.py index 7bf42d39c..28d5f54fa 100644 --- a/tests/roundtrip_test.py +++ b/tests/builder/roundtrip_test.py @@ -30,9 +30,8 @@ def test_empty_font(self): self.assertUFORoundtrip(empty_font) def test_GlyphsUnitTestSans(self): - self.skipTest("TODO") filename = os.path.join(os.path.dirname(__file__), - 'data/GlyphsUnitTestSans.glyphs') + '../data/GlyphsUnitTestSans.glyphs') with open(filename) as f: font = glyphsLib.load(f) self.assertUFORoundtrip(font) diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py new file mode 100644 index 000000000..e33d0dda7 --- /dev/null +++ b/tests/builder/to_glyphs_test.py @@ -0,0 +1,527 @@ +# coding=UTF-8 +# +# Copyright 2016 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 pytest +import datetime +import os + +import defcon + +from glyphsLib.builder.constants import GLYPHS_COLORS, GLYPHLIB_PREFIX +from glyphsLib import to_glyphs, to_ufos, to_designspace +from glyphsLib import classes + +from fontTools.designspaceLib import DesignSpaceDocument + +# TODO: (jany) think hard about the ordering and RTL/LTR +# TODO: (jany) make one generic test with data using pytest + + +@pytest.mark.skip +def test_anchors_with_same_name_correct_order_rtl(): + ufo = defcon.Font() + g = ufo.newGlyph('laam_alif') + # Append the anchors in the correct order + g.appendAnchor(dict(x=50, y=600, name='top')) + g.appendAnchor(dict(x=250, y=600, name='top')) + + font = to_glyphs([ufo]) + + top1, top2 = font.glyphs['laam_alif'].layers[0].anchors + + assert top1.name == 'top_1' + assert top1.x == 50 + assert top1.y == 600 + assert top2.name == 'top_2' + assert top2.x == 250 + assert top2.y == 600 + + +@pytest.mark.skip +def test_anchors_with_same_name_wrong_order_rtl(): + ufo = defcon.Font() + g = ufo.newGlyph('laam_alif') + # Append the anchors in the wrong order + g.appendAnchor(dict(x=250, y=600, name='top')) + g.appendAnchor(dict(x=50, y=600, name='top')) + + font = to_glyphs([ufo]) + + top1, top2 = font.glyphs['laam_alif'].layers[0].anchors + + # FIXME: (jany) think hard about the ordering and LTR + assert top1.name == 'top_1' + assert top1.x == 50 + assert top1.y == 600 + assert top2.name == 'top_2' + assert top2.x == 250 + assert top2.y == 600 + + +@pytest.mark.skip +def test_anchors_with_same_name_correct_order_ltr(): + ufo = defcon.Font() + g = ufo.newGlyph('laam_alif') + # Append the anchors in the correct order + g.appendAnchor(dict(x=50, y=600, name='top')) + g.appendAnchor(dict(x=250, y=600, name='top')) + + font = to_glyphs([ufo]) + + top1, top2 = font.glyphs['laam_alif'].layers[0].anchors + + # FIXME: (jany) think hard about the ordering and RTL/LTR + assert top1.name == 'top_1' + assert top1.x == 50 + assert top1.y == 600 + assert top2.name == 'top_2' + assert top2.x == 250 + assert top2.y == 600 + + +@pytest.mark.skip +def test_anchors_with_same_name_wrong_order_ltr(): + ufo = defcon.Font() + g = ufo.newGlyph('laam_alif') + # Append the anchors in the wrong order + g.appendAnchor(dict(x=250, y=600, name='top')) + g.appendAnchor(dict(x=50, y=600, name='top')) + + font = to_glyphs([ufo]) + + top1, top2 = font.glyphs['laam_alif'].layers[0].anchors + + # FIXME: (jany) think hard about the ordering and LTR + assert top1.name == 'top_1' + assert top1.x == 50 + assert top1.y == 600 + assert top2.name == 'top_2' + assert top2.x == 250 + assert top2.y == 600 + + +def test_groups(): + ufo = defcon.Font() + ufo.newGlyph('T') + ufo.newGlyph('e') + ufo.newGlyph('o') + samekh = ufo.newGlyph('samekh-hb') + samekh.unicode = 0x05E1 + resh = ufo.newGlyph('resh-hb') + resh.unicode = 0x05E8 + ufo.groups['public.kern1.T'] = ['T'] + ufo.groups['public.kern2.oe'] = ['o', 'e'] + ufo.groups['com.whatever.Te'] = ['T', 'e'] + # Groups can contain glyphs that are not in the font and that should + # be preserved as well + ufo.groups['public.kern1.notInFont'] = ['L'] + ufo.groups['public.kern1.halfInFont'] = ['o', 'b', 'p'] + ufo.groups['com.whatever.notInFont'] = ['i', 'j'] + # Empty groups as well (found in the wild) + ufo.groups['public.kern1.empty'] = [] + ufo.groups['com.whatever.empty'] = [] + # Groups for RTL glyphs. In a UFO RTL kerning pair, kern1 is for the glyph + # on the left visually (the first that gets written when writing RTL) + # The example below with Resh and Samekh comes from: + # https://forum.glyphsapp.com/t/dramatic-bug-in-hebrew-kerning/4093 + ufo.groups['public.kern1.hebrewLikeT'] = ['resh-hb'] + ufo.groups['public.kern2.hebrewLikeO'] = ['samekh-hb'] + groups_dict = dict(ufo.groups) + + # TODO: (jany) add a test with 2 UFOs with conflicting data + # TODO: (jany) add a test with with both UFO groups and feature file classes + # TODO: (jany) add a test with UFO groups that conflict with feature file classes + font = to_glyphs([ufo], minimize_ufo_diffs=True) + + # Kerning for existing glyphs is stored in GSGlyph.left/rightKerningGroup + assert font.glyphs['T'].rightKerningGroup == 'T' + assert font.glyphs['o'].leftKerningGroup == 'oe' + assert font.glyphs['e'].leftKerningGroup == 'oe' + # In Glyphs, rightKerningGroup and leftKerningGroup refer to the sides of + # the glyph, they don't swap for RTL glyphs + assert font.glyphs['resh-hb'].leftKerningGroup == 'hebrewLikeT' + assert font.glyphs['samekh-hb'].rightKerningGroup == 'hebrewLikeO' + + # Non-kerning groups are stored as classes + assert font.classes['com.whatever.Te'].code == 'T e' + assert font.classes['com.whatever.notInFont'].code == 'i j' + # Kerning groups with some characters not in the font are also saved + # somehow, but we don't care how, that fact will be better tested by the + # roundtrip test a few lines below + + ufo, = to_ufos(font) + + # Check that nothing has changed + assert dict(ufo.groups) == groups_dict + + # Check that changing the `left/rightKerningGroup` fields in Glyphs + # updates the UFO kerning groups + font.glyphs['T'].rightKerningGroup = 'newNameT' + font.glyphs['o'].rightKerningGroup = 'onItsOwnO' + + del groups_dict['public.kern1.T'] + groups_dict['public.kern1.newNameT'] = ['T'] + groups_dict['public.kern1.halfInFont'].remove('o') + groups_dict['public.kern1.onItsOwnO'] = ['o'] + + ufo, = to_ufos(font) + + assert dict(ufo.groups) == groups_dict + + +def test_guidelines(): + ufo = defcon.Font() + a = ufo.newGlyph('a') + for obj in [ufo, a]: + # Complete guideline + obj.appendGuideline(dict( + x=10, + y=20, + angle=30, + name="lc", + color="1,0,0,1", + identifier="lc1")) + # Don't crash if a guideline misses information + obj.appendGuideline({'x': 10}) + obj.appendGuideline({'y': 20}) + obj.appendGuideline({}) + + font = to_glyphs([ufo]) + + for gobj in [font.masters[0], font.glyphs['a'].layers[0]]: + assert len(gobj.guides) == 4 + + angled, vertical, horizontal, empty = gobj.guides + + assert angled.position.x == 10 + assert angled.position.y == 20 + assert angled.angle == 330 + assert angled.name == "lc [1,0,0,1] [#lc1]" + + assert vertical.position.x == 10 + assert vertical.angle == 90 + + assert horizontal.position.y == 20 + assert horizontal.angle == 0 + + ufo, = to_ufos(font) + + for obj in [ufo, ufo['a']]: + angled, vertical, horizontal, empty = obj.guidelines + + assert angled.x == 10 + assert angled.y == 20 + assert angled.angle == 30 + assert angled.name == 'lc' + assert angled.color == '1,0,0,1' + assert angled.identifier == 'lc1' + + assert vertical.x == 10 + assert vertical.y is None + assert vertical.angle is None + + assert horizontal.x is None + assert horizontal.y == 20 + assert horizontal.angle is None + + +def test_glyph_color(): + ufo = defcon.Font() + a = ufo.newGlyph('a') + a.markColor = GLYPHS_COLORS[3] + b = ufo.newGlyph('b') + b.markColor = '{:.04f},{:.04f},0,1'.format(4.0 / 255, 128.0 / 255) + + font = to_glyphs([ufo]) + + assert font.glyphs['a'].color == 3 + assert font.glyphs['b'].color == [4, 128, 0, 255] + + ufo, = to_ufos(font) + + assert ufo['a'].markColor == GLYPHS_COLORS[3] + assert ufo['b'].markColor == b.markColor + + +def test_bad_ufo_date_format_in_glyph_lib(): + ufo = defcon.Font() + a = ufo.newGlyph('a') + a.lib[GLYPHLIB_PREFIX + 'lastChange'] = '2017-12-19 15:12:44 +0000' + + # Don't crash + font = to_glyphs([ufo]) + + assert (font.glyphs['a'].lastChange == + datetime.datetime(2017, 12, 19, 15, 12, 44)) + + +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. + """ + thin = defcon.Font() + thin.info.openTypeOS2WidthClass = 5 + thin.info.openTypeOS2WeightClass = 100 + regular = defcon.Font() + regular.info.openTypeOS2WidthClass = 5 + regular.info.openTypeOS2WeightClass = 400 + bold = defcon.Font() + bold.info.openTypeOS2WidthClass = 5 + bold.info.openTypeOS2WeightClass = 700 + thin_expanded = defcon.Font() + thin_expanded.info.openTypeOS2WidthClass = 7 + thin_expanded.info.openTypeOS2WeightClass = 100 + bold_ultra_cond = defcon.Font() + bold_ultra_cond.info.openTypeOS2WidthClass = 1 + bold_ultra_cond.info.openTypeOS2WeightClass = 700 + + font = to_glyphs([thin, regular, bold, thin_expanded, bold_ultra_cond]) + + gthin, greg, gbold, gthinex, gbolducond = font.masters + + # For weight, copy the WeightClass as-is + assert gthin.weightValue == 100 + assert greg.weightValue == 400 + assert gbold.weightValue == 700 + assert gthinex.weightValue == 100 + assert gbolducond.weightValue == 700 + + # For width, use the "% of normal" column from the spec + # https://www.microsoft.com/typography/otspec/os2.htm#wdc + assert gthin.widthValue == 100 + assert greg.widthValue == 100 + assert gbold.widthValue == 100 + assert gthinex.widthValue == 125 + assert gbolducond.widthValue == 50 + + +def test_designspace_source_locations(tmpdir): + """Check that opening UFOs from their source descriptor works with both + the filename and the path attributes. + """ + designspace_path = os.path.join(str(tmpdir), 'test.designspace') + light_ufo_path = os.path.join(str(tmpdir), 'light.ufo') + bold_ufo_path = os.path.join(str(tmpdir), 'bold.ufo') + + designspace = DesignSpaceDocument() + light_source = designspace.newSourceDescriptor() + light_source.filename = 'light.ufo' + designspace.addSource(light_source) + bold_source = designspace.newSourceDescriptor() + bold_source.path = bold_ufo_path + designspace.addSource(bold_source) + designspace.write(designspace_path) + + light = defcon.Font() + light.info.ascender = 30 + light.save(light_ufo_path) + + bold = defcon.Font() + bold.info.ascender = 40 + bold.save(bold_ufo_path) + + designspace = DesignSpaceDocument() + designspace.read(designspace_path) + + font = to_glyphs(designspace) + + assert len(font.masters) == 2 + assert font.masters[0].ascender == 30 + assert font.masters[1].ascender == 40 + + +@pytest.mark.skip(reason='Should be better defined') +def test_ufo_filename_is_kept_the_same(tmpdir): + """Check that the filenames of existing UFOs are correctly written to + the designspace document when doing UFOs -> Glyphs -> designspace. + This only works when the option "minimize_ufo_diffs" is given, because + keeping track of this information adds stuff to the Glyphs file. + """ + light_ufo_path = os.path.join(str(tmpdir), 'light.ufo') + bold_ufo_path = os.path.join(str(tmpdir), 'subdir/bold.ufo') + + light = defcon.Font() + light.info.ascender = 30 + light.save(light_ufo_path) + + bold = defcon.Font() + bold.info.ascender = 40 + bold.save(bold_ufo_path) + + # First check: when going from UFOs -> Glyphs -> designspace + font = to_glyphs([light, bold], minimize_ufo_diffs=True) + + designspace = to_designspace(font) + assert designspace.sources[0].path == light_ufo_path + assert designspace.sources[1].path == bold_ufo_path + + # Second check: going from designspace -> Glyphs -> designspace + designspace_path = os.path.join(str(tmpdir), 'test.designspace') + designspace = DesignSpaceDocument() + light_source = designspace.newSourceDescriptor() + light_source.filename = 'light.ufo' + designspace.addSource(light_source) + bold_source = designspace.newSourceDescriptor() + bold_source.path = bold_ufo_path + designspace.addSource(bold_source) + designspace.write(designspace_path) + + font = to_glyphs([light, bold], minimize_ufo_diffs=True) + + assert designspace.sources[0].filename == 'light.ufo' + assert designspace.sources[1].filename == 'subdir/bold.ufo' + + +def test_dont_copy_advance_to_the_background_unless_it_was_there(tmpdir): + ufo = defcon.Font() + bg = ufo.newLayer('public.background') + + fg_a = ufo.newGlyph('a') + fg_a.width = 100 + bg_a = bg.newGlyph('a') + + fg_b = ufo.newGlyph('b') + fg_b.width = 200 + bg_b = bg.newGlyph('b') + bg_b.width = 300 + + fg_c = ufo.newGlyph('c') + fg_c.width = 400 + bg_c = bg.newGlyph('c') + bg_c.width = 400 + + font = to_glyphs([ufo]) + path = os.path.join(str(tmpdir), 'test.glyphs') + font.save(path) + saved_font = classes.GSFont(path) + + for font in [font, saved_font]: + ufo, = to_ufos(font) + + assert ufo['a'].width == 100 + assert ufo.layers['public.background']['a'].width == 0 + assert ufo['b'].width == 200 + assert ufo.layers['public.background']['b'].width == 300 + assert ufo['c'].width == 400 + assert ufo.layers['public.background']['c'].width == 400 + + +def test_dont_zero_width_of_nonspacing_marks_if_it_was_not_zero(): + # TODO + pass + + +def test_double_unicodes(tmpdir): + ufo = defcon.Font() + z = ufo.newGlyph('z') + z.unicodes = [0x005A, 0x007A] + + font = to_glyphs([ufo]) + path = os.path.join(str(tmpdir), 'test.glyphs') + font.save(path) + saved_font = classes.GSFont(path) + + for font in [font, saved_font]: + assert font.glyphs['z'].unicode == "005A" + assert font.glyphs['z'].unicodes == ["005A", "007A"] + + ufo, = to_ufos(font) + + assert ufo['z'].unicodes == [0x005A, 0x007A] + + +def test_open_contour(): + ufo = defcon.Font() + a = ufo.newGlyph('a') + pen = a.getPen() + pen.moveTo((10, 20)) + pen.lineTo((30, 40)) + pen.endPath() + + font = to_glyphs([ufo]) + + path = font.glyphs['a'].layers[0].paths[0] + assert not path.closed + assert len(path.nodes) == 2 + assert path.nodes[0].type == classes.LINE + + ufo_rt, = to_ufos(font) + + assert ([(p.segmentType, p.x, p.y) for p in a[0]] == + [(p.segmentType, p.x, p.y) for p in ufo_rt['a'][0]]) + + +def test_background_before_foreground(): + ufo = defcon.Font() + a = ufo.newGlyph('a') + background = ufo.newLayer('public.background') + a_bg = background.newGlyph('a') + + ufo.layers.layerOrder = ['public.background', 'public.default'] + + # Check that it does not crash + font = to_glyphs([ufo]) + + +def test_only_background(): + ufo = defcon.Font() + background = ufo.newLayer('public.background') + a_bg = background.newGlyph('a') + + # Check that it does not crash + font = to_glyphs([ufo]) + + +def test_warn_diff_between_designspace_and_ufos(caplog): + ufo = defcon.Font() + ufo.info.familyName = 'UFO Family Name' + ufo.info.styleName = 'UFO Style Name' + # ufo.info.styleMapFamilyName = 'UFO Stylemap Family Name' + # ufo.info.styleMapStyleName = 'bold' + + doc = DesignSpaceDocument() + source = doc.newSourceDescriptor() + source.font = ufo + source.familyName = 'DS Family Name' + source.styleName = 'DS Style Name' + doc.addSource(source) + + font = to_glyphs(doc, minimize_ufo_diffs=True) + assert any(record.levelname == 'WARNING' for record in caplog.records) + assert 'The familyName is different between the UFO and the designspace source' in caplog.text + assert 'The styleName is different between the UFO and the designspace source' in caplog.text + + doc = to_designspace(font) + source = doc.sources[0] + + # The UFO info will prevail + assert ufo.info.familyName == 'UFO Family Name' + assert ufo.info.styleName == 'UFO Style Name' + assert source.font.info.familyName == 'UFO Family Name' + assert source.font.info.styleName == 'UFO Style Name' + + +def test_custom_stylemap_style_name(): + ufo = defcon.Font() + ufo.info.styleMapStyleName = 'bold' # Not "regular" + + font = to_glyphs([ufo], minimize_ufo_diffs=True) + ufo, = to_ufos(font) + + assert ufo.info.styleMapStyleName == 'bold' diff --git a/tests/classes_test.py b/tests/classes_test.py index 6d9a938fb..b749a82ec 100755 --- a/tests/classes_test.py +++ b/tests/classes_test.py @@ -20,18 +20,19 @@ import os import datetime -import unittest import copy +import unittest +import pytest from fontTools.misc.py23 import unicode from glyphsLib.classes import ( GSFont, GSFontMaster, GSInstance, GSCustomParameter, GSGlyph, GSLayer, GSAnchor, GSComponent, GSAlignmentZone, GSClass, GSFeature, GSAnnotation, GSFeaturePrefix, GSGuideLine, GSHint, GSNode, GSSmartComponentAxis, - LayerComponentsProxy, LayerGuideLinesProxy, + GSBackgroundImage, LayerComponentsProxy, LayerGuideLinesProxy, STEM, TEXT, ARROW, CIRCLE, PLUS, MINUS ) -from glyphsLib.types import point, transform, rect, size +from glyphsLib.types import Point, Transform, Rect, Size TESTFILE_PATH = os.path.join( os.path.dirname(__file__), @@ -514,7 +515,7 @@ def test_attributes(self): master.guides = [] self.assertEqual(len(master.guides), 0) newGuide = GSGuideLine() - newGuide.position = point("{100, 100}") + newGuide.position = Point("{100, 100}") newGuide.angle = -10.0 master.guides.append(newGuide) self.assertIsNotNone(master.guides[0].__repr__()) @@ -527,7 +528,7 @@ def test_attributes(self): master.guides = [] self.assertEqual(len(master.guides), 0) newGuide = GSGuideLine() - newGuide.position = point("{100, 100}") + newGuide.position = Point("{100, 100}") newGuide.angle = -10.0 master.guides.append(newGuide) self.assertIsNotNone(master.guides[0].__repr__()) @@ -562,22 +563,87 @@ def test_name(self): self.assertEqual('Light', master.name) master.italicAngle = 11 - self.assertEqual('Light Italic', master.name) + # self.assertEqual('Light Italic', master.name) + # That doesn't do anything in the latest Glyphs (1114) + self.assertEqual('Light', master.name) master.italicAngle = 0 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) + # Test the name of a master set to "Regular" in the UI dropdown + # but with a customName + thin = GSFontMaster() + thin.customName = 'Thin' + self.assertEqual('Thin', thin.name) + + def test_name_assignment(self): + test_data = [ + # Regular + ('Regular', '', '', ''), + # Weights from the dropdown + ('Light', 'Light', '', ''), + ('SemiLight', 'SemiLight', '', ''), + ('SemiBold', 'SemiBold', '', ''), + ('Bold', 'Bold', '', ''), + # Widths from the dropdown + ('Condensed', '', 'Condensed', ''), + ('SemiCondensed', '', 'SemiCondensed', ''), + ('SemiExtended', '', 'SemiExtended', ''), + ('Extended', '', 'Extended', ''), + # Mixed weight and width from dropdowns + ('Light Condensed', 'Light', 'Condensed', ''), + ('Bold SemiExtended', 'Bold', 'SemiExtended', ''), + # With italic -- in Glyphs 1114 works like a custom part + ('Light Italic', 'Light', '', 'Italic'), + ('SemiLight Italic', 'SemiLight', '', 'Italic'), + ('SemiBold Italic', 'SemiBold', '', 'Italic'), + ('Bold Italic', 'Bold', '', 'Italic'), + ('Condensed Italic', '', 'Condensed', 'Italic'), + ('SemiCondensed Italic', '', 'SemiCondensed', 'Italic'), + ('SemiExtended Italic', '', 'SemiExtended', 'Italic'), + ('Extended Italic', '', 'Extended', 'Italic'), + ('Light Condensed Italic', 'Light', 'Condensed', 'Italic'), + ('Bold SemiExtended Italic', 'Bold', 'SemiExtended', 'Italic'), + # With custom parts + ('Thin', '', '', 'Thin'), + ('SemiLight Ultra Expanded', 'SemiLight', '', 'Ultra Expanded'), + ('Bold Compressed', 'Bold', '', 'Compressed'), + ('Fat Condensed', '', 'Condensed', 'Fat'), + ('Ultra Light Extended', 'Light', 'Extended', 'Ultra'), + ('Hyper Light Condensed Dunhill', 'Light', 'Condensed', 'Hyper Dunhill'), + ('Bold SemiExtended Rugged', 'Bold', 'SemiExtended', 'Rugged'), + ('Thin Italic', '', '', 'Thin Italic'), + ('SemiLight Ultra Expanded Italic', 'SemiLight', '', 'Ultra Expanded Italic'), + ('Bold Compressed Italic', 'Bold', '', 'Compressed Italic'), + ('Fat Condensed Italic', '', 'Condensed', 'Fat Italic'), + ('Ultra Light Extended Italic', 'Light', 'Extended', 'Ultra Italic'), + ('Hyper Light Condensed Dunhill Italic', 'Light', 'Condensed', 'Hyper Dunhill Italic'), + ('Bold SemiExtended Rugged Italic', 'Bold', 'SemiExtended', 'Rugged Italic'), + ('Green Light Red Extended Blue', 'Light', 'Extended', 'Green Red Blue'), + ('Green SemiExtended Red SemiBold Blue', 'SemiBold', 'SemiExtended', 'Green Red Blue'), + ] + master = GSFontMaster() + for name, weight, width, custom in test_data: + master.name = name + self.assertEqual(master.name, name) + self.assertEqual(master.weight, weight or 'Regular') + self.assertEqual(master.width, width or 'Regular') + self.assertEqual(master.customName, custom) + + 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): @@ -699,6 +765,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): @@ -961,7 +1036,7 @@ def test_guides(self): layer.guides = [] self.assertEqual(len(layer.guides), 0) newGuide = GSGuideLine() - newGuide.position = point("{100, 100}") + newGuide.position = Point("{100, 100}") newGuide.angle = -10.0 amount = len(layer.guides) layer.guides.append(newGuide) @@ -1069,7 +1144,7 @@ def test_anchors(self): layer.anchors['top'] = GSAnchor() self.assertGreaterEqual(len(layer.anchors), 1) self.assertIsNotNone(layer.anchors['top'].__repr__()) - layer.anchors['top'].position = point("{100, 100}") + layer.anchors['top'].position = Point("{100, 100}") # anchor = copy.copy(layer.anchors['top']) del layer.anchors['top'] layer.anchors['top'] = GSAnchor() @@ -1114,6 +1189,32 @@ def test_widthMetricsKey(self): def test_background(self): self.assertIn('GSBackgroundLayer', self.layer.background.__repr__()) + def test_backgroundImage(self): + # The selected layer (0 of glyph 'a') doesn't have one + self.assertIsNone(self.layer.backgroundImage) + + glyph = self.font.glyphs['A'] + layer = glyph.layers[0] + image = layer.backgroundImage + self.assertIsInstance(image, GSBackgroundImage) + # Values from the file + self.assertEqual('A.jpg', image.path) + self.assertEqual([0.0, 0.0, 489.0, 637.0], list(image.crop)) + # Default values + self.assertEqual(50, image.alpha) + self.assertEqual([1, 0, 0, 1, 0, 0], image.transform.value) + self.assertEqual(False, image.locked) + + # Test documented behaviour of "alpha" + image.alpha = 10 + self.assertEqual(10, image.alpha) + image.alpha = 9 + self.assertEqual(50, image.alpha) + image.alpha = 100 + self.assertEqual(100, image.alpha) + image.alpha = 101 + self.assertEqual(50, image.alpha) + # TODO? # bezierPath, openBezierPath, completeBezierPath, completeOpenBezierPath? @@ -1205,7 +1306,7 @@ def test_delete_and_add(self): self.assertEqual(len(layer.components), 2) def test_position(self): - self.assertIsInstance(self.component.position, point) + self.assertIsInstance(self.component.position, Point) def test_componentName(self): self.assertUnicode(self.component.componentName) @@ -1217,11 +1318,11 @@ def test_rotation(self): self.assertFloat(self.component.rotation) def test_transform(self): - self.assertIsInstance(self.component.transform, transform) + self.assertIsInstance(self.component.transform, Transform) self.assertEqual(len(self.component.transform.value), 6) def test_bounds(self): - self.assertIsInstance(self.component.bounds, rect) + self.assertIsInstance(self.component.bounds, Rect) bounds = self.component.bounds self.assertEqual(bounds.origin.x, 80) self.assertEqual(bounds.origin.y, -10) @@ -1322,16 +1423,16 @@ def test_nodes(self): for node in path.nodes: self.assertEqual(node.parent, path) amount = len(path.nodes) - newNode = GSNode(point("{100, 100}")) + newNode = GSNode(Point("{100, 100}")) path.nodes.append(newNode) self.assertEqual(newNode, path.nodes[-1]) del path.nodes[-1] - newNode = GSNode(point("{20, 20}")) + newNode = GSNode(Point("{20, 20}")) path.nodes.insert(0, newNode) self.assertEqual(newNode, path.nodes[0]) path.nodes.remove(path.nodes[0]) - newNode1 = GSNode(point("{10, 10}")) - newNode2 = GSNode(point("{20, 20}")) + newNode1 = GSNode(Point("{10, 10}")) + newNode2 = GSNode(Point("{20, 20}")) path.nodes.extend([newNode1, newNode2]) self.assertEqual(newNode1, path.nodes[-2]) self.assertEqual(newNode2, path.nodes[-1]) @@ -1377,7 +1478,7 @@ def test_repr(self): self.assertIsNotNone(self.node.__repr__()) def test_position(self): - self.assertIsInstance(self.node.position, point) + self.assertIsInstance(self.node.position, Point) def test_type(self): self.assertTrue(self.node.type in @@ -1454,5 +1555,65 @@ def test_plistValue_dict(self): ) +class GSBackgroundLayerTest(unittest.TestCase): + """Goal: forbid in glyphsLib all the GSLayer.background APIs that don't + work in Glyphs.app, so that the code we write for glyphsLib is sure to + work in Glyphs.app + """ + def setUp(self): + self.layer = GSLayer() + self.bg = self.layer.background + + def test_get_GSLayer_background(self): + """It should always return a GSLayer (actually a GSBackgroundLayer but + it's a subclass of GSLayer so it's ok) + """ + self.assertIsInstance(self.bg, GSLayer) + bg2 = self.layer.background + self.assertEqual(self.bg, bg2) + + def test_set_GSLayer_background(self): + """It should raise because it behaves strangely in Glyphs.app. + The only way to modify a background layer in glyphsLib is to get it + from a GSLayer object. + """ + with pytest.raises(AttributeError): + self.layer.background = GSLayer() + + def test_get_GSLayer_foreground(self): + """It should raise AttributeError, as in Glyphs.app""" + with pytest.raises(AttributeError): + fg = self.layer.foreground + + def test_set_GSLayer_foreground(self): + with pytest.raises(AttributeError): + self.layer.foreground = GSLayer() + + def test_get_GSBackgroundLayer_background(self): + """It should always return None, as in Glyphs.app""" + self.assertIsNone(self.bg.background) + + def test_set_GSBackgroundLayer_background(self): + """It should raise because it should not be possible.""" + with pytest.raises(AttributeError): + self.bg.background = GSLayer() + + def test_get_GSBackgroundLayer_foreground(self): + """It should return the foreground layer. + + WARNING: currently in Glyphs.app it is not implemented properly and it + returns some Objective C function. + """ + self.assertEqual(self.layer, self.bg.foreground) + + def test_set_GSBackgroundLayer_foreground(self): + """It should raise AttributeError, because it would be too complex to + implement properly and anyway in Glyphs.app it returns some Objective C + function. + """ + with pytest.raises(AttributeError): + self.bg.foreground = GSLayer() + + if __name__ == '__main__': unittest.main() diff --git a/tests/data/DesignspaceGenTestItalic.designspace b/tests/data/DesignspaceGenTestItalic.designspace new file mode 100644 index 000000000..8396c3522 --- /dev/null +++ b/tests/data/DesignspaceGenTestItalic.designspace @@ -0,0 +1,44 @@ + + + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/DesignspaceGenTestRegular.designspace b/tests/data/DesignspaceGenTestRegular.designspace new file mode 100644 index 000000000..c7a503214 --- /dev/null +++ b/tests/data/DesignspaceGenTestRegular.designspace @@ -0,0 +1,44 @@ + + + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 80938c14a..b2c4a6052 100644 --- a/tests/data/DesignspaceTestTwoAxes.designspace +++ b/tests/data/DesignspaceTestTwoAxes.designspace @@ -1,17 +1,17 @@ - - - - - + Weight + + + + - - - + Width + + @@ -21,97 +21,97 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + diff --git a/tests/data/GlyphsUnitTestSans.glyphs b/tests/data/GlyphsUnitTestSans.glyphs index edcdcc74f..b9ff2eb0a 100644 --- a/tests/data/GlyphsUnitTestSans.glyphs +++ b/tests/data/GlyphsUnitTestSans.glyphs @@ -1,5 +1,5 @@ { -.appVersion = "1087"; +.appVersion = "1114"; DisplayStrings = ( A ); @@ -107,9 +107,7 @@ horizontalStems = ( 16, 18 ); -iconName = Light; id = "C4872ECA-A3A9-40AB-960A-1DB2202F16DE"; -name = Light; userData = { GSOffsetHorizontal = 9; GSOffsetMakeStroke = 1; @@ -185,7 +183,6 @@ horizontalStems = ( 88, 91 ); -iconName = Regular; id = "3E7589AA-8194-470F-8E2F-13C1C581BE24"; userData = { GSOffsetHorizontal = 45; @@ -257,9 +254,7 @@ horizontalStems = ( 210, 215 ); -iconName = Bold; id = "BFFFD157-90D3-4B85-B99D-9A2F366F03CA"; -name = Bold; userData = { GSOffsetHorizontal = 115; GSOffsetMakeStroke = 1; @@ -359,11 +354,13 @@ hints = ( horizontal = 1; origin = "{1, 1}"; target = "{1, 0}"; +type = Stem; }, { horizontal = 1; origin = "{0, 1}"; target = "{0, 4}"; +type = Stem; } ); layerId = "3E7589AA-8194-470F-8E2F-13C1C581BE24"; diff --git a/tests/data/MontserratStrippedDown.glyphs b/tests/data/MontserratStrippedDown.glyphs new file mode 100644 index 000000000..376b68f6b --- /dev/null +++ b/tests/data/MontserratStrippedDown.glyphs @@ -0,0 +1,1010 @@ +{ +.appVersion = "1114"; +classes = ( +{ +automatic = 1; +code = A; +name = Uppercase; +} +); +copyright = "Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)"; +customParameters = ( +{ +name = fsType; +value = ( +); +}, +{ +name = "Use Typo Metrics"; +value = 1; +}, +{ +name = license; +value = "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: http://scripts.sil.org/OFL"; +}, +{ +name = licenseURL; +value = "http://scripts.sil.org/OFL"; +}, +{ +name = vendorID; +value = ULA; +}, +{ +name = note; +value = "strokes\012softsigns\012tick"; +}, +{ +name = "Disable Last Change"; +value = 1; +}, +{ +name = "Use Line Breaks"; +value = 1; +} +); +date = "2017-10-17 18:36:54 +0000"; +designer = "Julieta Ulanovsky"; +designerURL = "http://www.zkysky.com.ar/"; +familyName = Montserrat; +featurePrefixes = ( +{ +automatic = 1; +code = "languagesystem DFLT dflt; +"; +name = Languagesystems; +} +); +fontMaster = ( +{ +alignmentZones = ( +"{742, 10}", +"{700, 10}", +"{600, 10}", +"{559, 10}", +"{517, 10}", +"{0, -10}", +"{-100, -10}", +"{-194, -10}" +); +ascender = 742; +capHeight = 700; +customParameters = ( +{ +name = typoAscender; +value = 968; +}, +{ +name = typoDescender; +value = -251; +}, +{ +name = typoLineGap; +value = 0; +}, +{ +name = winAscent; +value = 1109; +}, +{ +name = winDescent; +value = 270; +}, +{ +name = hheaAscender; +value = 968; +}, +{ +name = hheaDescender; +value = -251; +}, +{ +name = hheaLineGap; +value = 0; +}, +{ +name = smallCapHeight; +value = 559; +}, +{ +name = paramArea; +value = "610"; +}, +{ +name = paramDepth; +value = "30"; +}, +{ +name = paramOver; +value = "6"; +} +); +descender = -194; +horizontalStems = ( +19, +19, +19, +19 +); +id = UUID0; +userData = { +GSCornerRadius = 185; +GSOffsetHorizontal = 10; +GSOffsetMakeStroke = 1; +GSOffsetProportional = 1; +GSOffsetVertical = -15; +}; +verticalStems = ( +20, +20, +20, +20 +); +weight = Light; +weightValue = 20; +xHeight = 517; +}, +{ +alignmentZones = ( +"{742, 15}", +"{700, 15}", +"{600, 15}", +"{570, 14}", +"{532, 15}", +"{0, -15}", +"{-100, -15}", +"{-194, -15}" +); +ascender = 742; +capHeight = 700; +customParameters = ( +{ +name = typoAscender; +value = 968; +}, +{ +name = typoDescender; +value = -251; +}, +{ +name = typoLineGap; +value = 0; +}, +{ +name = winAscent; +value = 1109; +}, +{ +name = winDescent; +value = 270; +}, +{ +name = hheaAscender; +value = 968; +}, +{ +name = hheaDescender; +value = -251; +}, +{ +name = hheaLineGap; +value = 0; +}, +{ +name = smallCapHeight; +value = 570; +}, +{ +name = paramArea; +value = "473"; +}, +{ +name = paramDepth; +value = "22"; +}, +{ +name = paramOver; +value = "2"; +} +); +descender = -194; +horizontalStems = ( +99, +102, +98, +96 +); +id = "708134FE-A11E-43C9-84F0-594DA15B6BD1"; +verticalStems = ( +114, +115, +110, +111 +); +weight = SemiBold; +weightValue = 110; +xHeight = 532; +}, +{ +alignmentZones = ( +"{742, 20}", +"{700, 20}", +"{600, 20}", +"{582, 18}", +"{547, 20}", +"{0, -20}", +"{-100, -20}", +"{-194, -20}" +); +ascender = 742; +capHeight = 700; +customParameters = ( +{ +name = typoAscender; +value = 968; +}, +{ +name = typoDescender; +value = -251; +}, +{ +name = typoLineGap; +value = 0; +}, +{ +name = winAscent; +value = 1109; +}, +{ +name = winDescent; +value = 270; +}, +{ +name = hheaAscender; +value = 968; +}, +{ +name = hheaDescender; +value = -251; +}, +{ +name = hheaLineGap; +value = 0; +}, +{ +name = smallCapHeight; +value = 582; +}, +{ +name = paramArea; +value = "287"; +}, +{ +name = paramDepth; +value = "12"; +}, +{ +name = paramOver; +value = "0"; +} +); +descender = -194; +guideLines = ( +{ +position = "{314, 609}"; +}, +{ +position = "{395, 716}"; +}, +{ +position = "{398, 742}"; +} +); +horizontalStems = ( +194, +191, +190, +176 +); +id = "5DA6E103-6A94-47F2-987D-4952DB8EA68E"; +userData = { +GSCornerRadius = 61; +GSOffsetHorizontal = 28; +GSOffsetPosition = 0; +GSOffsetProportional = 1; +GSOffsetVertical = 20; +}; +verticalStems = ( +236, +244, +226, +229 +); +weight = Bold; +weightValue = 226; +xHeight = 547; +} +); +glyphs = ( +{ +glyphname = A; +layers = ( +{ +anchors = ( +{ +name = bottom; +position = "{344, 0}"; +}, +{ +name = ogonek; +position = "{679, 0}"; +}, +{ +name = top; +position = "{344, 700}"; +}, +{ +name = topleft; +position = "{106, 700}"; +} +); +background = { +paths = ( +{ +closed = 1; +nodes = ( +"32 0 LINE", +"349 684 LINE", +"339 684 LINE", +"656 0 LINE", +"679 0 LINE", +"354 700 LINE", +"334 700 LINE", +"9 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"581 187 LINE", +"571 206 LINE", +"116 206 LINE", +"106 187 LINE" +); +} +); +}; +layerId = UUID0; +paths = ( +{ +closed = 1; +nodes = ( +"32 0 LINE", +"349 684 LINE", +"339 684 LINE", +"656 0 LINE", +"679 0 LINE", +"354 700 LINE", +"334 700 LINE", +"9 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"571 211 LINE", +"561 230 LINE", +"126 230 LINE", +"116 211 LINE" +); +} +); +width = 688; +}, +{ +anchors = ( +{ +name = bottom; +position = "{403, 0}"; +}, +{ +name = ogonek; +position = "{825, 0}"; +}, +{ +name = top; +position = "{403, 700}"; +}, +{ +name = topleft; +position = "{121, 701}"; +} +); +background = { +paths = ( +{ +closed = 1; +nodes = ( +"221 0 LINE", +"447 588 LINE", +"355 588 LINE", +"581 0 LINE", +"825 0 LINE", +"519 700 LINE", +"287 700 LINE", +"-19 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"605 102 LINE", +"545 272 LINE", +"223 272 LINE", +"163 102 LINE" +); +} +); +}; +guideLines = ( +{ +angle = 245.556; +position = "{234, 56}"; +} +); +layerId = "5DA6E103-6A94-47F2-987D-4952DB8EA68E"; +paths = ( +{ +closed = 1; +nodes = ( +"221 0 LINE", +"447 588 LINE", +"355 588 LINE", +"581 0 LINE", +"825 0 LINE", +"519 700 LINE", +"287 700 LINE", +"-19 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"605 122 LINE", +"545 292 LINE", +"223 292 LINE", +"163 122 LINE" +); +} +); +width = 806; +}, +{ +anchors = ( +{ +name = bottom; +position = "{370, 0}"; +}, +{ +name = ogonek; +position = "{743, 0}"; +}, +{ +name = top; +position = "{370, 700}"; +}, +{ +name = topleft; +position = "{113, 700}"; +} +); +background = { +anchors = ( +{ +name = bottom; +position = "{370, 0}"; +}, +{ +name = ogonek; +position = "{743, 0}"; +}, +{ +name = top; +position = "{370, 700}"; +}, +{ +name = topleft; +position = "{113, 700}"; +} +); +paths = ( +{ +closed = 1; +nodes = ( +"115 0 LINE", +"392 637 LINE", +"346 637 LINE", +"623 0 LINE", +"743 0 LINE", +"426 700 LINE", +"313 700 LINE", +"-3 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"586 168 LINE", +"554 260 LINE", +"168 260 LINE", +"137 168 LINE" +); +} +); +}; +layerId = "708134FE-A11E-43C9-84F0-594DA15B6BD1"; +paths = ( +{ +closed = 1; +nodes = ( +"115 0 LINE", +"392 637 LINE", +"346 637 LINE", +"623 0 LINE", +"743 0 LINE", +"426 700 LINE", +"313 700 LINE", +"-3 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"586 168 LINE", +"554 260 LINE", +"168 260 LINE", +"137 168 LINE" +); +} +); +width = 740; +} +); +leftKerningGroup = A; +rightKerningGroup = A; +rightMetricsKey = "=|"; +unicode = 0041; +} +); +instances = ( +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 20; +instanceInterpolations = { +UUID0 = 1; +}; +name = Thin; +weightClass = Thin; +}, +{ +customParameters = ( +{ +name = weightClass; +value = 275; +}, +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 33; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.14444; +UUID0 = 0.85556; +}; +name = ExtraLight; +weightClass = ExtraLight; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 50; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.33333; +UUID0 = 0.66667; +}; +name = Light; +weightClass = Light; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 71; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.56667; +UUID0 = 0.43333; +}; +name = Regular; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 96; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.84444; +UUID0 = 0.15556; +}; +name = Medium; +weightClass = Medium; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 125; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 0.12931; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.87069; +}; +name = SemiBold; +weightClass = SemiBold; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 156; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 0.39655; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.60345; +}; +isBold = 1; +name = Bold; +weightClass = Bold; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 190; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 0.68966; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.31034; +}; +name = ExtraBold; +weightClass = ExtraBold; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +} +); +interpolationWeight = 226; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 1; +}; +name = Black; +weightClass = Black; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 20; +instanceInterpolations = { +UUID0 = 1; +}; +name = Thin; +weightClass = Thin; +}, +{ +customParameters = ( +{ +name = weightClass; +value = 275; +}, +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 33; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.14444; +UUID0 = 0.85556; +}; +name = ExtraLight; +weightClass = ExtraLight; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 50; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.33333; +UUID0 = 0.66667; +}; +name = Light; +weightClass = Light; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 71; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.56667; +UUID0 = 0.43333; +}; +name = Regular; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 96; +instanceInterpolations = { +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.84444; +UUID0 = 0.15556; +}; +name = Medium; +weightClass = Medium; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 125; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 0.12931; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.87069; +}; +name = SemiBold; +weightClass = SemiBold; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 156; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 0.39655; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.60345; +}; +isBold = 1; +name = Bold; +weightClass = Bold; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 190; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 0.68966; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = 0.31034; +}; +name = ExtraBold; +weightClass = ExtraBold; +}, +{ +customParameters = ( +{ +name = "Update Features"; +value = 1; +}, +{ +name = Filter; +value = AddExtremes; +}, +{ +name = familyName; +value = "Montserrat Alternates"; +} +); +interpolationWeight = 226; +instanceInterpolations = { +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = 1; +}; +name = Black; +weightClass = Black; +} +); +manufacturer = "Julieta Ulanovsky"; +manufacturerURL = "http://www.zkysky.com.ar/"; +unitsPerEm = 1000; +userData = { +GSDimensionPlugin.Dimensions = { +"1C84A7F3-C40A-4DC1-B8B4-102D183C348F" = { +HV = 20; +}; +"5DA6E103-6A94-47F2-987D-4952DB8EA68E" = { +HH = "194"; +HV = "236"; +OH = "191"; +OV = "238"; +nV = "226"; +nd = "190"; +oH = "176"; +oV = "229"; +tH = "165"; +}; +"708134FE-A11E-43C9-84F0-594DA15B6BD1" = { +HH = "99"; +HV = "114"; +}; +"D46AABC6-AB7D-4C0B-AE2B-3079AD121F41" = { +HV = 232; +}; +UUID0 = { +HH = 20; +HV = 20; +OH = 20; +OV = 20; +nV = 20; +nd = 15; +oH = 20; +oV = 20; +tH = 20; +}; +UUID1 = { +HH = 150; +HV = 190; +OH = 165; +OV = 200; +}; +}; +}; +versionMajor = 7; +versionMinor = 200; +} diff --git a/tests/downloaded/.gitignore b/tests/downloaded/.gitignore new file mode 100644 index 000000000..b668b90f0 --- /dev/null +++ b/tests/downloaded/.gitignore @@ -0,0 +1,5 @@ +* +!.gitignore + +# This folder is for various tests files that can be downloaded from +# GitHub repositories for extended integration tests. diff --git a/tests/interpolation_test.py b/tests/interpolation_test.py deleted file mode 100644 index 4cf222c3c..000000000 --- a/tests/interpolation_test.py +++ /dev/null @@ -1,414 +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.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 os.path.sep == '\\': - # On windows, the test must not fail because of a difference between - # forward and backward slashes in filname paths. - # The failure happens because of line 217 of "mutatorMath\ufo\document.py" - # > pathRelativeToDocument = os.path.relpath(fileName, os.path.dirname(self.path)) - expected = [line.replace('filename="out/', 'filename="out\\') for line in expected] - 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/main_test.py b/tests/main_test.py index 30b477112..4a22838df 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -20,23 +20,64 @@ import unittest import subprocess import os +import glob -import test_helpers - - -class MainTest(unittest.TestCase, test_helpers.AssertLinesEqual): - def test_parser_main(self): - """This is both a test for the "main" functionality of glyphsLib.parser - and for the round-trip of GlyphsUnitTestSans.glyphs. - """ - filename = os.path.join( - os.path.dirname(__file__), 'data/GlyphsUnitTestSans.glyphs') - with open(filename) as f: - expected = f.read() - out = subprocess.check_output( - ['python', '-m', 'glyphsLib.parser', filename], - universal_newlines=True) # Windows gives \r\n otherwise - self.assertLinesEqual( - str(expected.splitlines()), - str(out.splitlines()), - 'The roundtrip should output the .glyphs file unmodified.') +import glyphsLib.__main__ +import glyphsLib.parser + + +def test_glyphs_main_masters(tmpdir): + """Tests the main of glyphsLib and also the `build_masters` function + that `fontmake` uses. + """ + filename = os.path.join( + os.path.dirname(__file__), 'data/GlyphsUnitTestSans.glyphs') + master_dir = os.path.join(str(tmpdir), 'master_ufos_test') + + glyphsLib.__main__.main(['-g', filename, '-m', master_dir]) + + assert glob.glob(master_dir + '/*.ufo') + + +def test_glyphs_main_instances(tmpdir): + filename = os.path.join( + os.path.dirname(__file__), 'data/GlyphsUnitTestSans.glyphs') + master_dir = os.path.join(str(tmpdir), 'master_ufos_test') + inst_dir = os.path.join(str(tmpdir), 'inst_ufos_test') + + glyphsLib.__main__.main(['-g', filename, '-m', master_dir, '-n', inst_dir]) + + assert glob.glob(master_dir + '/*.ufo') + assert glob.glob(inst_dir + '/*.ufo') + + +def test_glyphs_main_instances_relative_dir(tmpdir): + filename = os.path.join( + os.path.dirname(__file__), 'data/GlyphsUnitTestSans.glyphs') + master_dir = 'master_ufos_test' + inst_dir = 'inst_ufos_test' + + cwd = os.getcwd() + try: + os.chdir(str(tmpdir)) + glyphsLib.__main__.main( + ['-g', filename, '-m', master_dir, '-n', inst_dir]) + + assert glob.glob(master_dir + '/*.ufo') + assert glob.glob(inst_dir + '/*.ufo') + finally: + os.chdir(cwd) + + +def test_parser_main(capsys): + """This is both a test for the "main" functionality of glyphsLib.parser + and for the round-trip of GlyphsUnitTestSans.glyphs. + """ + filename = os.path.join( + os.path.dirname(__file__), 'data/GlyphsUnitTestSans.glyphs') + with open(filename) as f: + expected = f.read() + + glyphsLib.parser.main([filename]) + out, _err = capsys.readouterr() + assert expected == out, 'The roundtrip should output the .glyphs file unmodified.' diff --git a/tests/parser_test.py b/tests/parser_test.py index 88172cc2e..9e3183657 100644 --- a/tests/parser_test.py +++ b/tests/parser_test.py @@ -23,9 +23,7 @@ import datetime from glyphsLib.parser import Parser -from glyphsLib.classes import GSGlyph, GSLayer -from glyphsLib.types import color, glyphs_datetime -from fontTools.misc.py23 import unicode +from glyphsLib.classes import GSGlyph GLYPH_DATA = '''\ ( @@ -97,6 +95,12 @@ def test_parse_str_inf(self): [('mystr', 'Inf')] ) + def test_parse_multiple_unicodes(self): + self.run_test( + b'{unicode = 0000,0008,001D;}', + [('unicode', ["0000", "0008", "001D"])] + ) + def test_parse_str_nan(self): self.run_test( b'{mystr = nan;}', @@ -120,33 +124,11 @@ def test_parse_dict_in_dict(self): [('outer', OrderedDict([('inner', 'turtles')]))] ) - -GLYPH_ATTRIBUTES = { - "bottomKerningGroup": str, - "bottomMetricsKey": str, - "category": str, - "color": color, - "export": bool, - # "glyphname": str, - "lastChange": glyphs_datetime, - "layers": GSLayer, - "leftKerningGroup": str, - "leftMetricsKey": str, - "name": str, - "note": unicode, - "partsSettings": dict, - "production": str, - "rightKerningGroup": str, - "rightMetricsKey": str, - "script": str, - "subCategory": str, - "topKerningGroup": str, - "topMetricsKey": str, - "unicode": str, - "userData": dict, - "vertWidthMetricsKey": str, - "widthMetricsKey": str, -} + def test_parse_base64_data(self): + self.run_test( + b'{key = ;}', + [('key', b'value')] + ) class ParserGlyphTest(unittest.TestCase): diff --git a/tests/run_roundtrip_on_noto.py b/tests/run_roundtrip_on_noto.py deleted file mode 100644 index 498881c1a..000000000 --- a/tests/run_roundtrip_on_noto.py +++ /dev/null @@ -1,69 +0,0 @@ -# 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. - -import subprocess -import os -import unittest -import re - -import test_helpers - -NOTO_DIRECTORY = os.path.join(os.path.dirname(__file__), 'noto-source-moyogo') -NOTO_GIT_URL = "https://github.com/moyogo/noto-source.git" -NOTO_GIT_BRANCH = "normalized-1071" - -APP_VERSION_RE = re.compile('\\.appVersion = "(.*)"') - - -def glyphs_files(directory): - for root, _dirs, files in os.walk(directory): - for filename in files: - if filename.endswith('.glyphs'): - yield os.path.join(root, filename) - - -def app_version(filename): - with open(filename) as fp: - for line in fp: - m = APP_VERSION_RE.match(line) - if m: - return m.group(1) - return "no_version" - - -class NotoRoundtripTest(unittest.TestCase, - test_helpers.AssertParseWriteRoundtrip): - pass - - -if not os.path.exists(NOTO_DIRECTORY): - subprocess.call(["git", "clone", NOTO_GIT_URL, NOTO_DIRECTORY]) -subprocess.check_call( - ["git", "-C", NOTO_DIRECTORY, "checkout", NOTO_GIT_BRANCH]) - -for index, filename in enumerate(glyphs_files(NOTO_DIRECTORY)): - def test_method(self, filename=filename): - self.assertParseWriteRoundtrip(filename) - file_basename = os.path.basename(filename) - test_name = "test_n{0:0>3d}_v{1}_{2}".format( - index, - app_version(filename), - file_basename.replace(r'[^a-zA-Z]', '')) - test_method.__name__ = test_name - setattr(NotoRoundtripTest, test_name, test_method) - - -if __name__ == '__main__': - import sys - sys.exit(unittest.main()) diff --git a/tests/run_various_tests_on_various_files.py b/tests/run_various_tests_on_various_files.py new file mode 100644 index 000000000..c14eb83c1 --- /dev/null +++ b/tests/run_various_tests_on_various_files.py @@ -0,0 +1,173 @@ +# 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. + +import subprocess +import os +import unittest +import pytest +import re + +import glyphsLib +from fontTools.designspaceLib import DesignSpaceDocument +import test_helpers + + +# Kinds of tests that can be run +class GlyphsRT(unittest.TestCase, test_helpers.AssertParseWriteRoundtrip): + """Test the parser & writer for .glyphs files only""" + + @classmethod + def add_tests(cls, testable): + files = test_helpers.glyphs_files(directory(testable)) + for index, filename in enumerate(sorted(files)): + + def test_method(self, filename=filename): + self.assertParseWriteRoundtrip(filename) + + file_basename = os.path.basename(filename) + test_name = "test_n{0:0>3d}_{1}_v{2}_{3}".format( + index, testable['name'], test_helpers.app_version(filename), + file_basename.replace(r'[^a-zA-Z]', '')) + test_method.__name__ = test_name + setattr(cls, test_name, test_method) + + +class GlyphsToDesignspaceRT(unittest.TestCase, + test_helpers.AssertUFORoundtrip): + """Test the whole chain from .glyphs to designspace + UFOs and back""" + + @classmethod + def add_tests(cls, testable): + files = test_helpers.glyphs_files(directory(testable)) + for index, filename in enumerate(sorted(files)): + + def test_method(self, filename=filename): + with open(filename) as f: + font = glyphsLib.load(f) + self.assertUFORoundtrip(font) + + file_basename = os.path.basename(filename) + test_name = "test_n{0:0>3d}_{1}_v{2}_{3}".format( + index, testable['name'], test_helpers.app_version(filename), + file_basename.replace(r'[^a-zA-Z]', '')) + test_method.__name__ = test_name + setattr(cls, test_name, test_method) + + +class DesignspaceToGlyphsRT(unittest.TestCase, + test_helpers.AssertDesignspaceRoundtrip): + """Test the whole chain from designspace + UFOs to .glyphs and back""" + + @classmethod + def add_tests(cls, testable): + files = test_helpers.designspace_files(directory(testable)) + for index, filename in enumerate(sorted(files)): + + def test_method(self, filename=filename): + doc = DesignSpaceDocument(writerClass=InMemoryDocWriter) + doc.read(filename) + self.assertDesignspaceRoundtrip(doc) + + file_basename = os.path.basename(filename) + test_name = "test_n{0:0>3d}_{1}_{2}".format( + index, testable['name'], + file_basename.replace(r'[^a-zA-Z]', '')) + test_method.__name__ = test_name + setattr(cls, test_name, test_method) + print("adding test", test_name) + + +class UFOsToGlyphsRT(unittest.TestCase): + """The the whole chain from a collection of UFOs to .glyphs and back""" + + @classmethod + def add_tests(cls, testable): + pass + + +TESTABLES = [ + # The following contain .glyphs files + { + 'name': 'noto_moyogo', # dirname inside `downloaded/` + 'git_url': 'https://github.com/moyogo/noto-source.git', + 'git_ref': 'normalized-1071', + 'classes': (GlyphsRT, GlyphsToDesignspaceRT), + }, + { + # https://github.com/googlei18n/glyphsLib/issues/238 + 'name': 'montserrat', + 'git_url': 'https://github.com/JulietaUla/Montserrat', + 'git_ref': 'master', + 'classes': (GlyphsRT, GlyphsToDesignspaceRT), + }, + { + # https://github.com/googlei18n/glyphsLib/issues/282 + 'name': 'cantarell_madig', + 'git_url': 'https://github.com/madig/cantarell-fonts/', + 'git_ref': 'f17124d041e6ee370a9fcddcc084aa6cbf3d5500', + 'classes': (GlyphsRT, GlyphsToDesignspaceRT), + }, + # { + # # This one has truckloads of smart components + # 'name': 'vt323', + # 'git_url': 'https://github.com/phoikoi/VT323', + # 'git_ref': 'master', + # 'classes': (GlyphsRT, GlyphsToDesignspaceRT), + # }, + { + # This one has truckloads of smart components + 'name': 'vt323_jany', + 'git_url': 'https://github.com/belluzj/VT323', + 'git_ref': 'glyphs-1089', + 'classes': (GlyphsRT, GlyphsToDesignspaceRT), + }, + # The following contain .designspace files + { + 'name': 'spectral', + 'git_url': 'https://github.com/productiontype/Spectral', + 'git_ref': 'master', + 'classes': (DesignspaceToGlyphsRT, UFOsToGlyphsRT), + }, + { + 'name': 'amstelvar', + 'git_url': 'https://github.com/TypeNetwork/fb-Amstelvar', + 'git_ref': 'master', + 'classes': (DesignspaceToGlyphsRT, UFOsToGlyphsRT), + }, +] + + +def directory(testable): + return os.path.join( + os.path.dirname(__file__), 'downloaded', testable['name']) + + +for testable in TESTABLES: + print("#### Downloading ", testable['name']) + if not os.path.exists(directory(testable)): + subprocess.call( + ["git", "clone", testable['git_url'], directory(testable)]) + subprocess.check_call( + ["git", "-C", directory(testable), "checkout", testable['git_ref']]) + print() + +for testable in TESTABLES: + for cls in testable['classes']: + cls.add_tests(testable) + + +if __name__ == '__main__': + import sys + # Run pytest.main because it's easier to filter tests, drop into PDB, etc. + sys.exit(pytest.main(sys.argv)) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 8c867b771..84a849acd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -15,13 +15,23 @@ # limitations under the License. import difflib +import os.path +import re +import subprocess import sys +import tempfile +import shutil +from collections import OrderedDict from textwrap import dedent import glyphsLib -from glyphsLib.builder import to_glyphs, to_ufos +from glyphsLib import classes +from fontTools.designspaceLib import DesignSpaceDocument +from glyphsLib.builder import to_glyphs, to_designspace from glyphsLib.writer import Writer from fontTools.misc.py23 import UnicodeIO +from ufonormalizer import normalizeUFO +import defcon def write_to_lines(glyphs_object): @@ -44,8 +54,8 @@ def assertLinesEqual(self, expected, actual, message): Some information may be LOST! """)) for line in difflib.unified_diff( - expected, actual, - fromfile="", tofile=""): + expected, actual, fromfile="", + tofile=""): if not line.endswith("\n"): line += "\n" sys.stderr.write(line) @@ -62,6 +72,12 @@ def assertParseWriteRoundtrip(self, filename): # Roundtrip again to check idempotence font = glyphsLib.loads("\n".join(actual)) actual_idempotent = write_to_lines(font) + with open('expected.txt', 'w') as f: + f.write('\n'.join(expected)) + with open('actual.txt', 'w') as f: + f.write('\n'.join(actual)) + with open('actual_indempotent.txt', 'w') as f: + f.write('\n'.join(actual_idempotent)) # Assert idempotence first, because if that fails it's a big issue self.assertLinesEqual( actual, actual_idempotent, @@ -70,11 +86,195 @@ def assertParseWriteRoundtrip(self, filename): expected, actual, "The writer should output exactly what the parser read") + class AssertUFORoundtrip(AssertLinesEqual): + """Check .glyphs -> UFOs + designspace -> .glyphs""" + def _normalize(self, font): + # Order the kerning OrderedDict alphabetically + # (because the ordering from Glyphs.app is random and that would be + # a bit silly to store it only for the purpose of nicer diffs in tests) + font.kerning = OrderedDict(sorted(map( + lambda i: (i[0], OrderedDict(sorted(map( + lambda j: (j[0], OrderedDict(sorted(j[1].items()))), + i[1].items()) + ))), + font.kerning.items()))) + def assertUFORoundtrip(self, font): + self._normalize(font) expected = write_to_lines(font) - roundtrip = to_glyphs(to_ufos(font)) + # Don't propagate anchors when intending to round-trip + designspace = to_designspace( + font, propagate_anchors=False, minimize_glyphs_diffs=True) + + # Check that round-tripping in memory is the same as writing on disk + roundtrip_in_mem = to_glyphs(designspace) + self._normalize(roundtrip_in_mem) + actual_in_mem = write_to_lines(roundtrip_in_mem) + + directory = tempfile.mkdtemp() + path = os.path.join(directory, font.familyName + '.designspace') + write_designspace_and_UFOs(designspace, path) + designspace_roundtrip = DesignSpaceDocument() + designspace_roundtrip.read(path) + roundtrip = to_glyphs(designspace_roundtrip) + self._normalize(roundtrip) actual = write_to_lines(roundtrip) + + with open('expected.txt', 'w') as f: + f.write('\n'.join(expected)) + with open('actual_in_mem.txt', 'w') as f: + f.write('\n'.join(actual_in_mem)) + with open('actual.txt', 'w') as f: + f.write('\n'.join(actual)) + self.assertLinesEqual( + actual_in_mem, actual, + "The round-trip in memory or written to disk should be equivalent") self.assertLinesEqual( expected, actual, - "The font has been modified by the roundtrip") + "The font should not be modified by the roundtrip") + + +def write_designspace_and_UFOs(designspace, path): + for source in designspace.sources: + basename = os.path.basename(source.filename) + ufo_path = os.path.join(os.path.dirname(path), basename) + source.filename = basename + source.path = ufo_path + source.font.save(ufo_path, formatVersion=3) + designspace.write(path) + + +def deboolized(object): + if isinstance(object, OrderedDict): + return OrderedDict([ + (key, deboolized(value)) for key, value in object.items()]) + if isinstance(object, dict): + return {key: deboolized(value) for key, value in object.items()} + if isinstance(object, list): + return [deboolized(value) for value in object] + + if isinstance(object, bool): + return 1 if object else 0 + + return object + + +def deboolize(lib): + for key, value in lib.items(): + # Force dirtying the font, because value == deboolized(value) + # since True == 1 in python, so defcon thinks nothing happens + lib[key] = None + lib[key] = deboolized(value) + + +def normalize_ufo_lib(path): + """Go through each `lib` element recursively and transform `bools` into + `int` because that's what's going to happen on round-trip with Glyphs. + """ + font = defcon.Font(path) + deboolize(font.lib) + for layer in font.layers: + deboolize(layer.lib) + for glyph in layer: + deboolize(glyph.lib) + font.save() + + +class AssertDesignspaceRoundtrip(object): + """Check UFOs + designspace -> .glyphs -> UFOs + designspace""" + def assertDesignspacesEqual(self, expected, actual, message=''): + directory = tempfile.mkdtemp() + + def git(*args): + return subprocess.check_output(["git", "-C", directory] + + list(args)) + + def clean_git_folder(): + with os.scandir(directory) as entries: + for entry in entries: + if entry.is_file() or entry.is_symlink(): + os.remove(entry.path) + elif entry.is_dir() and entry.name != ".git": + shutil.rmtree(entry.path) + + # Strategy: init a git repo, dump expected, commit, dump actual, diff + designspace_filename = os.path.join(directory, 'test.designspace') + git("init") + write_designspace_and_UFOs(expected, designspace_filename) + for source in expected.sources: + normalize_ufo_lib(source.path) + normalizeUFO(source.path, floatPrecision=3, writeModTimes=False) + git("add", ".") + git("commit", "-m", "expected") + + clean_git_folder() + write_designspace_and_UFOs(actual, designspace_filename) + for source in actual.sources: + normalize_ufo_lib(source.path) + normalizeUFO(source.path, floatPrecision=3, writeModTimes=False) + git("add", ".") + status = git("status") + diff = git("diff", "--staged", + "--src-prefix= original/", "--dst-prefix=roundtrip/") + + if diff: + sys.stderr.write(status) + sys.stderr.write(diff) + + self.assertEqual(0, len(diff), message) + + def assertDesignspaceRoundtrip(self, designspace): + directory = tempfile.mkdtemp() + font = to_glyphs(designspace, minimize_ufo_diffs=True) + + # Check that round-tripping in memory is the same as writing on disk + roundtrip_in_mem = to_designspace(font, propagate_anchors=False) + + tmpfont_path = os.path.join(directory, 'font.glyphs') + font.save(tmpfont_path) + font_rt = classes.GSFont(tmpfont_path) + roundtrip = to_designspace(font_rt, propagate_anchors=False) + + font.save('intermediary.glyphs') + + write_designspace_and_UFOs(designspace, 'expected/test.designspace') + for source in designspace.sources: + normalize_ufo_lib(source.path) + normalizeUFO(source.path, floatPrecision=3, writeModTimes=False) + write_designspace_and_UFOs(roundtrip, 'actual/test.designspace') + for source in roundtrip.sources: + normalize_ufo_lib(source.path) + normalizeUFO(source.path, floatPrecision=3, writeModTimes=False) + self.assertDesignspacesEqual( + roundtrip_in_mem, roundtrip, + "The round-trip in memory or written to disk should be equivalent") + self.assertDesignspacesEqual( + designspace, roundtrip, + "The font should not be modified by the roundtrip") + + +APP_VERSION_RE = re.compile('\\.appVersion = "(.*)"') + + +def glyphs_files(directory): + for root, _dirs, files in os.walk(directory): + for filename in files: + if filename.endswith('.glyphs'): + yield os.path.join(root, filename) + + +def app_version(filename): + with open(filename) as fp: + for line in fp: + m = APP_VERSION_RE.match(line) + if m: + return m.group(1) + return "no_version" + + +def designspace_files(directory): + for root, _dirs, files in os.walk(directory): + for filename in files: + if filename.endswith('.designspace'): + yield os.path.join(root, filename) diff --git a/tests/types_test.py b/tests/types_test.py index 6d7288b38..f9b52e2e4 100644 --- a/tests/types_test.py +++ b/tests/types_test.py @@ -20,7 +20,7 @@ import datetime import unittest -from glyphsLib.types import glyphs_datetime +from glyphsLib.types import Transform, parse_datetime class GlyphsDateTimeTest(unittest.TestCase): @@ -28,15 +28,13 @@ class GlyphsDateTimeTest(unittest.TestCase): def test_parsing_24hr_format(self): """Assert glyphs_datetime can parse 24 hour time formats""" string_24hrs = '2017-01-01 17:30:30 +0000' - test_time = glyphs_datetime() - self.assertEqual(test_time.read(string_24hrs), + self.assertEqual(parse_datetime(string_24hrs), datetime.datetime(2017, 1, 1, 17, 30, 30)) def test_parsing_12hr_format(self): """Assert glyphs_datetime can parse 12 hour time format""" string_12hrs = '2017-01-01 5:30:30 PM +0000' - test_time = glyphs_datetime() - self.assertEqual(test_time.read(string_12hrs), + self.assertEqual(parse_datetime(string_12hrs), datetime.datetime(2017, 1, 1, 17, 30, 30)) def test_parsing_timezone(self): @@ -44,21 +42,27 @@ def test_parsing_timezone(self): formatted as UTC offset. If it's not explicitly specified, then +0000 is assumed. """ - self.assertEqual(glyphs_datetime().read('2017-12-18 16:45:31 -0100'), + self.assertEqual(parse_datetime('2017-12-18 16:45:31 -0100'), datetime.datetime(2017, 12, 18, 15, 45, 31)) - self.assertEqual(glyphs_datetime().read('2017-12-18 14:15:31 +0130'), + self.assertEqual(parse_datetime('2017-12-18 14:15:31 +0130'), datetime.datetime(2017, 12, 18, 15, 45, 31)) - self.assertEqual(glyphs_datetime().read('2017-12-18 15:45:31'), + self.assertEqual(parse_datetime('2017-12-18 15:45:31'), datetime.datetime(2017, 12, 18, 15, 45, 31)) - self.assertEqual(glyphs_datetime().read('2017-12-18 03:45:31 PM'), + self.assertEqual(parse_datetime('2017-12-18 03:45:31 PM'), datetime.datetime(2017, 12, 18, 15, 45, 31)) - self.assertEqual(glyphs_datetime().read('2017-12-18 09:45:31 AM'), + self.assertEqual(parse_datetime('2017-12-18 09:45:31 AM'), datetime.datetime(2017, 12, 18, 9, 45, 31)) +class TransformTest(unittest.TestCase): + def test_value_equality(self): + assert Transform(1, 0, 0, 1, 0, 0) == Transform(1, 0, 0, 1, 0, 0) + assert Transform(1, 0, 0, 1, 0, 0) == Transform(1.0, 0, 0, 1.0, 0, 0) + + if __name__ == '__main__': unittest.main() diff --git a/tests/util_test.py b/tests/util_test.py new file mode 100644 index 000000000..175b8187d --- /dev/null +++ b/tests/util_test.py @@ -0,0 +1,39 @@ +# coding=UTF-8 +# +# Copyright 2016 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 unittest + +from glyphsLib.util import bin_to_int_list, int_list_to_bin + +class UtilTest(unittest.TestCase): + def test_bin_to_int_list(self): + self.assertEqual([], bin_to_int_list(0)) + self.assertEqual([0], bin_to_int_list(1)) + self.assertEqual([1], bin_to_int_list(2)) + self.assertEqual([0, 1], bin_to_int_list(3)) + self.assertEqual([2], bin_to_int_list(4)) + self.assertEqual([7, 30], bin_to_int_list((1 << 7) + (1 << 30))) + + def test_int_list_to_bin(self): + self.assertEqual(int_list_to_bin([]), 0) + self.assertEqual(int_list_to_bin([0]), 1) + self.assertEqual(int_list_to_bin([1]), 2) + self.assertEqual(int_list_to_bin([0, 1]), 3) + self.assertEqual(int_list_to_bin([2]), 4) + self.assertEqual(int_list_to_bin([7, 30]), (1 << 7) + (1 << 30)) diff --git a/tests/writer_test.py b/tests/writer_test.py index e18fc8796..6dfcf1d49 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -24,12 +24,13 @@ import glyphsLib from glyphsLib import classes -from glyphsLib.types import glyphs_datetime, point, rect +from glyphsLib.types import parse_datetime, Point, Rect from glyphsLib.writer import dump, dumps from glyphsLib.parser import Parser import test_helpers + class WriterTest(unittest.TestCase, test_helpers.AssertLinesEqual): def assertWrites(self, glyphs_object, text): @@ -102,7 +103,7 @@ def test_write_font_attributes(self): # versionMinor font.versionMinor = 104 # date - font.date = glyphs_datetime('2017-10-03 07:35:46 +0000') + font.date = parse_datetime('2017-10-03 07:35:46 +0000') # familyName font.familyName = "Sans Rien" # upm @@ -183,12 +184,14 @@ def test_write_font_attributes(self): { ascender = 800; capHeight = 700; + descender = -200; id = M1; xHeight = 500; }, { ascender = 800; capHeight = 700; + descender = -200; id = M2; xHeight = 500; } @@ -239,6 +242,13 @@ def test_write_font_attributes(self): written = test_helpers.write_to_lines(font) self.assertFalse(any("keyboardIncrement" in line for line in written)) + # Always write versionMajor and versionMinor, even when 0 + font.versionMajor = 0 + font.versionMinor = 0 + written = test_helpers.write_to_lines(font) + self.assertIn("versionMajor = 0;", written) + self.assertIn("versionMinor = 0;", written) + def test_write_font_master_attributes(self): """Test the writer on all GSFontMaster attributes""" master = classes.GSFontMaster() @@ -246,9 +256,8 @@ def test_write_font_master_attributes(self): # id master.id = "MASTER-ID" # name - # Cannot set the `name` attribute directly - # master.name = "Hairline Megawide" - master.customParameters['Master Name'] = "Hairline Megawide" + master._name = "Name Hairline Megawide" + master.customParameters['Master Name'] = "Param Hairline Megawide" # weight master.weight = "Thin" # width @@ -259,14 +268,11 @@ def test_write_font_master_attributes(self): master.widthValue = 0.99 # customValue # customName - master.customName = "cuteness" + master.customName = "Overextended" # A value of 0.0 is not written to the file. master.customValue = 0.001 - master.customName1 = "color" master.customValue1 = 0.1 - master.customName2 = "depth" master.customValue2 = 0.2 - master.customName3 = "surealism" master.customValue3 = 0.3 # ascender master.ascender = 234.5 @@ -304,18 +310,15 @@ def test_write_font_master_attributes(self): ); ascender = 234.5; capHeight = 200.6; - custom = cuteness; + custom = Overextended; customValue = 0.001; - custom1 = color; customValue1 = 0.1; - custom2 = depth; customValue2 = 0.2; - custom3 = surealism; customValue3 = 0.3; customParameters = ( { name = "Master Name"; - value = "Hairline Megawide"; + value = "Param Hairline Megawide"; }, { name = underlinePosition; @@ -335,7 +338,7 @@ def test_write_font_master_attributes(self): ); id = "MASTER-ID"; italicAngle = 12.2; - name = "Hairline Megawide"; + name = "Name Hairline Megawide"; userData = { rememberToMakeTea = 1; }; @@ -367,7 +370,6 @@ def test_write_instance(self): instance = classes.GSInstance() # List of properties from https://docu.glyphsapp.com/#gsinstance # active - # FIXME: (jany) does not seem to be handled by this library? No doc? instance.active = True # name instance.name = "SemiBoldCompressed (name)" @@ -412,13 +414,11 @@ def test_write_instance(self): instance.manualInterpolation = True # interpolatedFont: read only - # FIXME: (jany) the weight and width are not in the output - # confusion with weightClass/widthClass? self.assertWrites(instance, dedent("""\ { customParameters = ( { - name = famiyName; + name = familyName; value = "Sans Rien (familyName)"; }, { @@ -458,6 +458,8 @@ def test_write_instance(self): linkStyle = "linked style value"; manualInterpolation = 1; name = "SemiBoldCompressed (name)"; + weightClass = "SemiBold (weight)"; + widthClass = "Compressed (width)"; } """)) @@ -616,7 +618,7 @@ def test_write_glyph(self): axis.name = "crotchDepth" glyph.smartComponentAxes.append(axis) # lastChange - glyph.lastChange = glyphs_datetime('2017-10-03 07:35:46 +0000') + glyph.lastChange = parse_datetime('2017-10-03 07:35:46 +0000') self.assertWrites(glyph, dedent("""\ { color = 11; @@ -628,7 +630,7 @@ def test_write_glyph(self): associatedMasterId = "MASTER-ID"; layerId = "LAYER-ID"; name = L1; - width = 0; + width = 600; } ); leftKerningGroup = A; @@ -682,6 +684,11 @@ def test_write_glyph(self): self.assertIn('category = "";', written) self.assertIn('subCategory = "";', written) + # Write double unicodes + glyph.unicodes = ['00C1', 'E002'] + written = test_helpers.write_to_lines(glyph) + self.assertIn('unicode = "00C1,E002";', written) + def test_write_layer(self): layer = classes.GSLayer() # http://docu.glyphsapp.com/#gslayer @@ -731,9 +738,7 @@ def test_write_layer(self): # bounds: read-only, computed # selectionBounds: read-only, computed # background - # FIXME: (jany) why not use a GSLayer like the official doc suggests? - background_layer = classes.GSBackgroundLayer() - layer.background = background_layer + bg = layer.background # backgroundImage image = classes.GSBackgroundImage('/path/to/file.jpg') layer.backgroundImage = image @@ -754,11 +759,11 @@ def test_write_layer(self): anchors = ( { name = top; + position = "{0, 0}"; } ); annotations = ( { - position = ; text = "Fuck, this curve is ugly!"; type = 1; } @@ -793,6 +798,7 @@ def test_write_layer(self): name = "{125, 100}"; paths = ( { + closed = 1; } ); userData = { @@ -811,8 +817,13 @@ def test_write_layer(self): written = test_helpers.write_to_lines(layer) self.assertNotIn('name = "";', written) + # Write the width even if 0 + layer.width = 0 + written = test_helpers.write_to_lines(layer) + self.assertIn('width = 0;', written) + def test_write_anchor(self): - anchor = classes.GSAnchor('top', point(23, 45.5)) + anchor = classes.GSAnchor('top', Point(23, 45.5)) self.assertWrites(anchor, dedent("""\ { name = top; @@ -820,11 +831,20 @@ def test_write_anchor(self): } """)) + # Write a position of 0, 0 + anchor = classes.GSAnchor('top', Point(0, 0)) + self.assertWrites(anchor, dedent("""\ + { + name = top; + position = "{0, 0}"; + } + """)) + def test_write_component(self): component = classes.GSComponent("dieresis") # http://docu.glyphsapp.com/#gscomponent # position - component.position = point(45.5, 250) + component.position = Point(45.5, 250) # scale component.scale = 2.0 # rotation @@ -897,7 +917,7 @@ def test_write_path(self): """)) def test_write_node(self): - node = classes.GSNode(point(10, 30), classes.GSNode.CURVE) + node = classes.GSNode(Point(10, 30), classes.GSNode.CURVE) # http://docu.glyphsapp.com/#gsnode # position: already set # type: already set @@ -917,7 +937,7 @@ def test_write_node(self): ) # Write floating point coordinates - node = classes.GSNode(point(499.99, 512.01), classes.GSNode.OFFCURVE) + node = classes.GSNode(Point(499.99, 512.01), classes.GSNode.OFFCURVE) self.assertWritesValue( node, '"499.99 512.01 OFFCURVE"' @@ -929,7 +949,7 @@ def test_write_node(self): ';': ';\n', 'escapeception': '\\"\\\'\\n\\\\n', } - node = classes.GSNode(point(130, 431), classes.GSNode.LINE) + node = classes.GSNode(Point(130, 431), classes.GSNode.LINE) for key, value in test_user_data.items(): node.userData[key] = value # This is the output of Glyphs 1089 @@ -942,7 +962,7 @@ def test_write_node(self): def test_write_guideline(self): line = classes.GSGuideLine() # http://docu.glyphsapp.com/#GSGuideLine - line.position = point(56, 45) + line.position = Point(56, 45) line.angle = 11.0 line.name = "italic angle" # selected: not written @@ -957,7 +977,7 @@ def test_write_guideline(self): def test_write_annotation(self): annotation = classes.GSAnnotation() # http://docu.glyphsapp.com/#gsannotation - annotation.position = point(12, 34) + annotation.position = Point(12, 34) annotation.type = classes.TEXT annotation.text = "Look here" annotation.angle = 123.5 @@ -978,21 +998,21 @@ def test_write_hint(self): layer = classes.GSLayer() path1 = classes.GSPath() layer.paths.append(path1) - node1 = classes.GSNode(point(100, 100)) + node1 = classes.GSNode(Point(100, 100)) path1.nodes.append(node1) hint.originNode = node1 - node2 = classes.GSNode(point(200, 200)) + node2 = classes.GSNode(Point(200, 200)) path1.nodes.append(node2) hint.targetNode = node2 - node3 = classes.GSNode(point(300, 300)) + node3 = classes.GSNode(Point(300, 300)) path1.nodes.append(node3) hint.otherNode1 = node3 path2 = classes.GSPath() layer.paths.append(path2) - node4 = classes.GSNode(point(400, 400)) + node4 = classes.GSNode(Point(400, 400)) path2.nodes.append(node4) hint.otherNode2 = node4 @@ -1024,16 +1044,15 @@ def test_write_hint(self): # written = test_helpers.write_to_lines(hint) # self.assertIn('target = up;', written) - def test_write_background_image(self): image = classes.GSBackgroundImage('/tmp/img.jpg') # http://docu.glyphsapp.com/#gsbackgroundimage # path: already set # image: read-only, objective-c - image.crop = rect(point(0, 10), point(500, 510)) + image.crop = Rect(Point(0, 10), Point(500, 510)) image.locked = True image.alpha = 70 - image.position = point(40, 90) + image.position = Point(40, 90) image.scale = (1.1, 1.2) image.rotation = 0.3 # transform: Already set with scale/rotation @@ -1043,14 +1062,7 @@ def test_write_background_image(self): crop = "{{0, 10}, {500, 510}}"; imagePath = "/tmp/img.jpg"; locked = 1; - transform = ( - 1.09998, - 0.00576, - -0.00628, - 1.19998, - 40, - 90 - ); + transform = "{1.09998, 0.00576, -0.00628, 1.19998, 40, 90}"; } """)) diff --git a/tox.ini b/tox.ini index 420a5ad50..f63452ac7 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = py27, py36, htmlcov deps = pytest coverage + ufonormalizer py27: mock>=2.0.0 -rrequirements.txt commands =