From aa326c2b757bd98eeff98c5af4dde044cedba26e Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 19 Oct 2017 16:57:47 +0100 Subject: [PATCH 01/44] Make the roundtrip work on GlyphsUnitTestSans WIP UFO round-trip WIP Glyphs and layers WIP kerning, hints, annotations WIP kerning, features WIP path, components WIP paths, features, kerning, glyph order WIP features, alignment zones, guidelines WIP Fix layer ordering WIP restore backround layers, anchor positions, path node order WIP custom parameters WIP Glyph, Layer, Node user data WIP layers WIP background image WIP fix some tests Fixup custom_params Fixup builder.glyph Fixup builder.guidelines WIP GSBackgroundLayer, glyphOrder WIP small fixes, refactor types.py WIP small fixes WIP smart component data WIP instances WIP roundtrip instances using designspace HELP IT'S AWFUL WIP use designSpaceDocument for round-trip WIP implement InMemorySourceDescriptor and test for writing on disk WIP instances --- .gitignore | 6 +- Lib/glyphsLib/__init__.py | 7 +- Lib/glyphsLib/builder/__init__.py | 37 +- Lib/glyphsLib/builder/anchors.py | 11 + Lib/glyphsLib/builder/annotations.py | 59 + Lib/glyphsLib/builder/background_image.py | 55 + Lib/glyphsLib/builder/blue_values.py | 24 +- Lib/glyphsLib/builder/builders.py | 171 +- Lib/glyphsLib/builder/common.py | 7 + Lib/glyphsLib/builder/components.py | 70 +- Lib/glyphsLib/builder/constants.py | 5 + Lib/glyphsLib/builder/custom_params.py | 681 +++-- Lib/glyphsLib/builder/features.py | 304 ++- Lib/glyphsLib/builder/filters.py | 17 +- Lib/glyphsLib/builder/font.py | 132 +- Lib/glyphsLib/builder/glyph.py | 240 +- Lib/glyphsLib/builder/guidelines.py | 28 +- Lib/glyphsLib/builder/hints.py | 59 + Lib/glyphsLib/builder/instances.py | 414 +++ Lib/glyphsLib/{ => builder}/interpolation.py | 22 +- Lib/glyphsLib/builder/kerning.py | 56 +- Lib/glyphsLib/builder/layers.py | 80 + Lib/glyphsLib/builder/names.py | 10 + Lib/glyphsLib/builder/paths.py | 47 +- Lib/glyphsLib/builder/user_data.py | 64 +- Lib/glyphsLib/classes.py | 477 ++-- Lib/glyphsLib/designSpaceDocument.py | 2358 +++++++++++++++++ Lib/glyphsLib/glyphdata.py | 1 + Lib/glyphsLib/types.py | 276 +- Lib/glyphsLib/util.py | 18 + tests/builder_test.py | 309 ++- tests/classes_test.py | 117 +- tests/data/DesignspaceTestTwoAxes.designspace | 4 +- tests/downloaded/.gitignore | 5 + tests/interpolation_test.py | 8 +- tests/main_test.py | 4 +- tests/parser_test.py | 29 - tests/roundtrip_test.py | 1 - tests/run_roundtrip_on_noto.py | 2 +- tests/run_various_tests_on_various_files.py | 77 + tests/test_helpers.py | 49 +- tests/types_test.py | 24 +- tests/util_test.py | 39 + tests/writer_test.py | 54 +- 44 files changed, 5647 insertions(+), 811 deletions(-) create mode 100644 Lib/glyphsLib/builder/annotations.py create mode 100644 Lib/glyphsLib/builder/background_image.py create mode 100644 Lib/glyphsLib/builder/hints.py create mode 100644 Lib/glyphsLib/builder/instances.py rename Lib/glyphsLib/{ => builder}/interpolation.py (95%) create mode 100644 Lib/glyphsLib/builder/layers.py create mode 100644 Lib/glyphsLib/designSpaceDocument.py create mode 100644 tests/downloaded/.gitignore create mode 100644 tests/run_various_tests_on_various_files.py create mode 100644 tests/util_test.py diff --git a/.gitignore b/.gitignore index 6c72109dd..af0f3c117 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,8 @@ 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* diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index e6c473231..c358ccbc6 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -21,15 +21,14 @@ from fontTools.misc.py23 import tostr +from glyphsLib.classes import __all__ as __all_classes__ +from glyphsLib.classes import * from glyphsLib.builder import to_ufos -from glyphsLib.interpolation import interpolate, build_designspace +from glyphsLib.builder.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 * - __version__ = "2.2.2.dev0" # Doing `import *` from a module that uses unicode_literals, produces diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index 019052132..5bcdf549b 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -17,6 +17,9 @@ from glyphsLib import classes import defcon +# FIXME: (jany) import from fonttools +from glyphsLib.designSpaceDocument import DesignSpaceDocument + from .builders import UFOBuilder, GlyphsBuilder logger = logging.getLogger(__name__) @@ -24,6 +27,7 @@ def to_ufos(font, include_instances=False, family_name=None, propagate_anchors=True, ufo_module=defcon): + # TODO: (jany) Update documentation """Take .glyphs file data and load it into UFOs. Takes in data as Glyphs.app-compatible classes, as documented at @@ -47,13 +51,40 @@ 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, propagate_anchors=True, + ufo_module=defcon): + # TODO: (jany) Update documentation + """Take .glyphs file data and load it into a Designspace Document + UFOS. + + 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. + """ + builder = UFOBuilder( + font, + ufo_module=ufo_module, + family_name=family_name, + propagate_anchors=propagate_anchors) + return builder.designspace + + +def to_glyphs(ufos_or_designspace, glyphs_module=classes): """ Take a list of UFOs and combine them into a single .glyphs file. This should be the inverse function of `to_ufos`, 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) + # FIXME: (jany) duck-type instead of isinstance + if isinstance(ufos_or_designspace, DesignSpaceDocument): + builder = GlyphsBuilder(designspace=ufos_or_designspace, + glyphs_module=glyphs_module) + else: + builder = GlyphsBuilder(ufos=ufos_or_designspace, + glyphs_module=glyphs_module) 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..19ddd6cd0 --- /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': + position = Point() + position.x, position.y = annot[attr] + annotation.position = position + else: + setattr(annotation, attr, annot[attr]) + layer.annotations.append(annotation) 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..dcfe5a067 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -17,11 +17,18 @@ from collections import OrderedDict import logging +import tempfile +import os import defcon -from glyphsLib import classes -from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX +# FIXME: import fontTools.designSpaceDocument +from glyphsLib import designSpaceDocument + +from glyphsLib import classes, glyphdata_generated +from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX + +GLYPH_ORDER_KEY = PUBLIC_PREFIX + 'glyphOrder' class _LoggerMixin(object): @@ -42,6 +49,7 @@ class UFOBuilder(_LoggerMixin): def __init__(self, font, ufo_module=defcon, + designspace_module=designSpaceDocument, family_name=None, propagate_anchors=True): """Create a builder that goes from Glyphs to UFO + designspace. @@ -51,18 +59,21 @@ 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. Should look like designSpaceDocument. family_name -- if provided, the master UFOs will be given this name and only instances with this name will be returned. propagate_anchors -- set to False to prevent anchor propagation """ self.font = font self.ufo_module = ufo_module + self.designspace_module = designspace_module # The set of UFOs (= defcon.Font objects) that will be built, # indexed by master ID, the same order as masters in the source GSFont. self._ufos = OrderedDict() - # The MutatorMath Designspace object that will be built (if requested). + # The designSpaceDocument object that will be built (if requested). self._designspace = None # check that source was generated with at least stable version 2.3 @@ -91,7 +102,6 @@ def __init__(self, self.propagate_anchors = propagate_anchors - @property def masters(self): """Get an iterator over master UFOs that match the given family_name. @@ -115,24 +125,9 @@ 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 @@ -149,17 +144,20 @@ def masters(self): ufo = self._ufos[layer_id] ufo_glyph = ufo.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, glyph) + ufo_layer = ufo.layers.defaultLayer + ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + + glyph.name] = self._layer_order_in_glyph(layer) - for layer_id, glyph_name, layer_name, layer_data \ + for master_id, glyph_name, layer_name, layer \ in supplementary_layer_data: - if (layer_data.layerId not in master_layer_ids - and layer_data.associatedMasterId not in master_layer_ids): + 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)) + self.font.familyName, glyph_name, layer.layerId)) continue if not layer_name: @@ -169,19 +167,22 @@ def masters(self): 'be skipped.'.format(self.font.familyName, glyph_name)) continue - ufo_font = self._ufos[layer_id] + ufo_font = self._ufos[master_id] if layer_name not in ufo_font.layers: ufo_layer = ufo_font.newLayer(layer_name) else: ufo_layer = ufo_font.layers[layer_name] + # TODO: (jany) move as much as possible into layers.py + ufo_layer.lib[GLYPHS_PREFIX + 'layerId'] = layer.layerId + ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + + glyph_name] = self._layer_order_in_glyph(layer) ufo_glyph = ufo_layer.newGlyph(glyph_name) - self.to_ufo_glyph(ufo_glyph, layer_data, layer_data.parent) + self.to_ufo_glyph(ufo_glyph, layer, layer.parent) for ufo in self._ufos.values(): - ufo.lib[glyphOrder_key] = glyph_order if self.propagate_anchors: self.to_ufo_propagate_font_anchors(ufo) - self.to_ufo_features(ufo) + self.to_ufo_features(ufo) # This depends on the glyphOrder key self.to_ufo_kerning_groups(ufo, kerning_groups) for master_id, kerning in self.font.kerning.items(): @@ -189,6 +190,13 @@ def masters(self): return self._ufos.values() + def _layer_order_in_glyph(self, layer): + # TODO: move to layers.py + # TODO: optimize? + for order, glyph_layer in enumerate(layer.parent.layers.values()): + if glyph_layer == layer: + return order + return None @property def instances(self): @@ -196,15 +204,21 @@ def instances(self): # TODO? return [] - @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 not None: + return self._designspace + self._designspace = self.designspace_module.DesignSpaceDocument( + writerClass=designSpaceDocument.InMemoryDocWriter, + fontClass=self.ufo_module.Font) + self.to_ufo_instances() + return self._designspace + # DEPRECATED @property def instance_data(self): instances = self.font.instances @@ -219,28 +233,33 @@ def instance_data(self): # 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 .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 .guidelines import to_ufo_guidelines + from .hints import to_ufo_hints + from .instances import to_ufo_instances from .kerning import (to_ufo_kerning, to_ufo_glyph_groups, to_ufo_kerning_groups) 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 .user_data import (to_ufo_family_user_data, to_ufo_master_user_data, + to_ufo_glyph_user_data, to_ufo_layer_user_data, + to_ufo_node_user_data) def filter_instances_by_family(instances, family_name=None): @@ -260,7 +279,7 @@ def filter_instances_by_family(instances, family_name=None): 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): """Create a builder that goes from UFOs + designspace to Glyphs. Keyword arguments: @@ -273,30 +292,88 @@ def __init__(self, ufos, designspace=None, glyphs_module=classes): module that holds the official classes to import UFOs into Glyphs.app) """ - self.ufos = ufos - self.designspace = designspace + if designspace is not None: + self.designspace = designspace + if ufos: + # assert all(ufo in designspace.getFonts() for ufo in ufos) + self.ufos = ufos + else: + self.ufos = [source.font for source in designspace.sources] + elif ufos: + self.designspace = None + self.ufos = ufos + else: + raise RuntimeError( + 'Please provide a designspace or at least one UFO.') self.glyphs_module = glyphs_module 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 + self.to_glyphs_ordered_masters() + self._font = self.glyphs_module.GSFont() for index, ufo in enumerate(self.ufos): + kerning_groups = self.to_glyphs_kerning_groups(ufo) + master = self.glyphs_module.GSFontMaster() self.to_glyphs_font_attributes(ufo, master, is_initial=(index == 0)) self._font.masters.insert(len(self._font.masters), master) - # TODO: all the other stuff! - return self._font + for layer in ufo.layers: + for glyph in layer: + self.to_glyphs_glyph(glyph, layer, master) + self.to_glyphs_glyph_groups(kerning_groups, glyph) + + self.to_glyphs_kerning(ufo, master) + + # Now that all GSGlyph are built, restore the glyph order + first_ufo = next(iter(self.ufos)) + 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)) + + # Restore the layer ordering in each glyph + for glyph in self._font.glyphs: + self.to_glyphs_layer_order(glyph) + + self.to_glyphs_instances() + + return self._font - # Implementation is spit into one file per feature - from .font import to_glyphs_font_attributes + # Implementation is split into one file per feature + from .anchors import to_glyphs_glyph_anchors + from .annotations import to_glyphs_annotations + 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_family_custom_params, + to_glyphs_master_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 .guidelines import to_glyphs_guidelines + from .hints import to_glyphs_hints + from .instances import to_glyphs_instances + from .kerning import (to_glyphs_glyph_groups, to_glyphs_kerning_groups, + to_glyphs_kerning) + from .layers import to_glyphs_layer, to_glyphs_layer_order + from .names import to_glyphs_family_names, to_glyphs_master_names + from .paths import to_glyphs_paths + from .user_data import (to_glyphs_family_user_data, + to_glyphs_master_user_data, + to_glyphs_glyph_user_data, + to_glyphs_layer_user_data, + to_glyphs_node_user_data) diff --git a/Lib/glyphsLib/builder/common.py b/Lib/glyphsLib/builder/common.py index 8646b92c8..77644a972 100644 --- a/Lib/glyphsLib/builder/common.py +++ b/Lib/glyphsLib/builder/common.py @@ -15,9 +15,16 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +import 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) 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..4ad8c3cba 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -15,163 +15,524 @@ 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 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 glyphs_names(self): + return [] + + # @abstractmethod + def ufo_names(self): + return [] + + # @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 + + def glyphs_names(self): + """Return the list of names that are handled + from the customParameters. + """ + # Just in case one handler covers several names + if self.glyphs_long_name: + return (self.glyphs_name, self.glyphs_long_name) + return (self.glyphs_name,) + + def ufo_names(self): + """Return the list of names that are handled from the lib.plist.""" + return (self.ufo_name,) + + # 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 _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 = [] +KNOWN_PARAM_GLYPHS_NAMES = set() +KNOWN_PARAM_UFO_NAMES = set() + + +def register(handler): + KNOWN_PARAM_HANDLERS.append(handler) + KNOWN_PARAM_GLYPHS_NAMES.update(handler.glyphs_names()) + KNOWN_PARAM_UFO_NAMES.update(handler.ufo_names()) + +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)) + +# 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], + value_to_glyphs=lambda value: [REVERSE_CODEPAGE_RANGES[v] for v in value] +)) +# 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: None +)) + +# 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 { + 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): + 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('customName' + number, ufo_info=False, + ufo_default='')) + register(MiscParamHandler('customValue' + number, ufo_info=False)) +register(MiscParamHandler('weightValue', ufo_info=False)) +register(MiscParamHandler('widthValue', ufo_info=False)) + + +class OS2SelectionParamHandler(AbstractParamHandler): + flags = ( + ('Has WWS Names', 8), + ('Use Typo Metrics', 7), + ) + + def glyphs_names(self): + return [flag[0] for flag in self.flags] + + def ufo_names(self): + return ('openTypeOS2Selection',) + + 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): + if ufo.get_info_value('openTypeOS2Selection') is None: + ufo.set_info_value('openTypeOS2Selection', []) + ufo.get_info_value('openTypeOS2Selection').append(value) + +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 + pass + +register(ReplaceFeatureParamHandler()) + 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) + # glyphs_module=None because we shouldn't instanciate any Glyphs classes + font_proxy = GlyphsObjectProxy(self.font, glyphs_module=None) + master_proxy = GlyphsObjectProxy(master, glyphs_module=None) + ufo_proxy = UFOProxy(ufo) - set_default_params(ufo) + for handler in KNOWN_PARAM_HANDLERS: + handler.to_ufo(font_proxy, ufo_proxy) + handler.to_ufo(master_proxy, ufo_proxy) + for param in font_proxy.unhandled_custom_parameters(): + name = _normalize_custom_param_name(param.name) + ufo.lib[CUSTOM_PARAM_PREFIX + font_proxy.sub_key + name] = param.value + for param in master_proxy.unhandled_custom_parameters(): + name = _normalize_custom_param_name(param.name) + ufo.lib[CUSTOM_PARAM_PREFIX + master_proxy.sub_key + name] = param.value -def set_custom_params(ufo, parsed=None, data=None, misc_keys=(), non_info=()): - """Set Glyphs custom parameters in UFO info or lib, where appropriate. + _set_default_params(ufo) - 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_glyphs_family_custom_params(self, ufo): + font_proxy = GlyphsObjectProxy(self.font, glyphs_module=self.glyphs_module) + ufo_proxy = UFOProxy(ufo) + # Handle known parameters + for handler in KNOWN_PARAM_HANDLERS: + handler.to_glyphs(font_proxy, ufo_proxy) - 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." + _to_glyphs_unknown_parameters(font_proxy, ufo_proxy) - fsSelection_flags = {'Use Typo Metrics', 'Has WWS Names'} - for name, value in parsed: - name = normalize_custom_param_name(name) + _unset_default_params(self.font) - 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 - 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 +def to_glyphs_master_custom_params(self, ufo, master): + master_proxy = GlyphsObjectProxy(master, glyphs_module=self.glyphs_module) + ufo_proxy = UFOProxy(ufo) + for handler in KNOWN_PARAM_HANDLERS: + handler.to_glyphs(master_proxy, ufo_proxy) - # 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 + _to_glyphs_unknown_parameters(master_proxy, ufo_proxy) + _unset_default_params(master) -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] - # 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 +def _to_glyphs_unknown_parameters(glyphs_proxy, ufo_proxy): + # TODO: (jany) Make sure that all parameters of the UFO info have a handler + # That way, only lib can have extra stuff + 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) -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 +545,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..b8a94667f 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -15,7 +15,12 @@ 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.feaLib import parser, ast +from fontTools.misc.py23 import StringIO import re @@ -32,7 +37,7 @@ def to_ufo_features(self, ufo): prefix_str = '\n\n'.join('# Prefix: %s\n%s%s' % (prefix.name, autostr(prefix.automatic), - prefix.code.strip()) + prefix.code) for prefix in self.font.featurePrefixes) class_defs = [] @@ -45,7 +50,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:') @@ -145,3 +150,298 @@ def replace_feature(tag, repl, features): features, count=1, flags=re.DOTALL | re.MULTILINE) + + +def to_glyphs_features(self, ufo): + 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) + + +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) + 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: + lines[0] = lines[0][begin_char - 1:] + lines[-1] = lines[-1][:end_char] + 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? + self._build_end_locations_rec(self._doc) + if self._doc.statements: + self._doc.statements[-1].end_location = ( + None, len(self._lines) + 1, len(self._lines[-1]) + 1) + + 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 = 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): + while self.statements.has_next(): + if (not self._process_prefix() and + not self._process_glyph_class_definition() and + not self._process_feature_block() and + not self._process_gdef_table_block()): + # FIXME: (jany) Discard other root-level comments... bad? + # Maybe put them in anonymous featurePrefixes + # FIXME: (jany) Maybe print warning about unhandled fea block? + # TODO: (jany) Check the list of all possible blocks in ast and + # handle them all (even if dummy implem) + self.statements.next() + + 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): + 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 + glyph_class.code = ' '.join(st.glyphSet()) + 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 + self.statements.next() + # TODO: (jany) + return True + + 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..d1a953428 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -18,11 +18,16 @@ from collections import deque, OrderedDict import logging -from .common import to_ufo_time +from .common import to_ufo_time, from_ufo_time from .constants import GLYPHS_PREFIX logger = logging.getLogger(__name__) +APP_VERSION_LIB_KEY = GLYPHS_PREFIX + 'appVersion' +KEYBOARD_INCREMENT_KEY = GLYPHS_PREFIX + 'keyboardIncrement' +MASTER_ID_LIB_KEY = GLYPHS_PREFIX + 'fontMasterID' +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,10 +50,14 @@ def to_ufo_font_attributes(self, family_name): designer_url = font.designerURL manufacturer = font.manufacturer manufacturer_url = font.manufacturerURL + glyph_order = list(glyph.name for glyph in font.glyphs) - for master in font.masters: + for index, master in enumerate(font.masters): ufo = self.ufo_module.Font() + 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 ufo.info.unitsPerEm = units_per_em @@ -95,6 +104,8 @@ def to_ufo_font_attributes(self, family_name): 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) @@ -103,8 +114,9 @@ def to_ufo_font_attributes(self, family_name): self.to_ufo_custom_params(ufo, master) master_id = master.id - ufo.lib[GLYPHS_PREFIX + 'fontMasterID'] = master_id - # FIXME: (jany) in the future, yield this UFO (for memory, laze iter) + ufo.lib[MASTER_ID_LIB_KEY] = master_id + ufo.lib[MASTER_ORDER_LIB_KEY] = index + # FIXME: (jany) in the future, yield this UFO (for memory, lazy iter) self._ufos[master_id] = ufo @@ -118,5 +130,113 @@ 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 + # TODO: (jany) when is_initial, write to context.font without question + # but when !is_initial, compare the last context.font.whatever and + # what we would be writing, to guard against the info being + # modified in only one of the UFOs in a MM. Maybe do this check later, + # when the roundtrip without modification works. + if is_initial: + _set_glyphs_font_attributes(self, ufo) + else: + # self._compare_and_merge_glyphs_font_attributes(ufo) + pass + _set_glyphs_master_attributes(self, ufo, master) + + +def _set_glyphs_font_attributes(self, ufo): + font = self.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(ufo) + self.to_glyphs_family_custom_params(ufo) + self.to_glyphs_features(ufo) + + +def _set_glyphs_master_attributes(self, ufo, master): + try: + master.id = ufo.lib[MASTER_ID_LIB_KEY] + except KeyError: + pass + + 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 + + try: + master.width = ufo.lib[GLYPHS_PREFIX + 'width'] + except KeyError: + pass + try: + master.weight = ufo.lib[GLYPHS_PREFIX + 'weight'] + except KeyError: + pass + + for number in ('', '1', '2', '3'): + name_key = GLYPHS_PREFIX + 'customName' + number + if name_key in ufo.lib: + custom_name = ufo.lib[name_key] + if custom_name: + setattr(master, 'customName' + number, custom_name) + value_key = GLYPHS_PREFIX + 'customValue' + number + if value_key in ufo.lib: + custom_value = ufo.lib[value_key] + if custom_value: + setattr(master, 'customValue' + number, custom_value) + + self.to_glyphs_blue_values(ufo, master) + self.to_glyphs_master_names(ufo, master) + self.to_glyphs_master_user_data(ufo, master) + self.to_glyphs_guidelines(ufo, master) + self.to_glyphs_master_custom_params(ufo, master) + + +def to_glyphs_ordered_masters(self): + """Modify in-place the list of UFOs to restore their original order.""" + self.ufos = sorted(self.ufos, key=_original_master_order) + + +def _original_master_order(ufo): + try: + return ufo.lib[MASTER_ORDER_LIB_KEY] + except: + return float('infinity') diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index 479752844..e6bde7799 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -17,67 +17,79 @@ 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 .constants import (GLYPHLIB_PREFIX, GLYPHS_COLORS, GLYPHS_PREFIX, PUBLIC_PREFIX) +SCRIPT_LIB_KEY = GLYPHLIB_PREFIX + 'script' + -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 + uval = glyph.unicode if uval is not None: ufo_glyph.unicode = int(uval, 16) - note = glyph_data.note + # FIXME: (jany) handle several unicodes + # https://github.com/googlei18n/glyphsLib/issues/216 + 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 + 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: ufo_glyph.font.lib[postscriptNamesKey] = dict() 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 + for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey', + 'leftKerningGroup', 'rightKerningGroup']: + 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: @@ -94,20 +106,129 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph_data): 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, 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): +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. + """ + + # 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 ufo_glyph.name in self.font.glyphs: + glyph = self.font.glyphs[ufo_glyph.name] + else: + glyph = self.glyphs_module.GSGlyph(name=ufo_glyph.name) + # FIXME: (jany) ordering? + self.font.glyphs.append(glyph) + + uval = ufo_glyph.unicode + if uval is not None: + glyph.unicode = '{:04X}'.format(uval) + # FIXME: (jany) handle several unicodes + # https://github.com/googlei18n/glyphsLib/issues/216 + 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'] + glyph.lastChange = from_ufo_time(last_change) + if ufo_glyph.markColor: + if GLYPHLIB_PREFIX + 'ColorIndex' in ufo_glyph.lib: + color_index = ufo_glyph.lib[GLYPHLIB_PREFIX + 'ColorIndex'] + if ufo_glyph.markColor == GLYPHS_COLORS[color_index]: + # Still coherent + glyph.color = color_index + else: + glyph.color = _to_glyphs_color_index(self, ufo_glyph.markColor) + else: + glyph.color = _to_glyphs_color_index(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', + 'leftKerningGroup', 'rightKerningGroup']: + for prefix, object in (('glyph.', glyph), ('layer.', layer)): + full_key = GLYPHLIB_PREFIX + prefix + key + if full_key in ufo_glyph.lib: + value = ufo_glyph.lib[full_key] + setattr(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: + 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 + layer.width = ufo_glyph.width + if category == 'Mark' and sub_category == 'Nonspacing' and layer.width == 0: + # Restore originalWidth + if GLYPHLIB_PREFIX + 'originalWidth' in ufo_glyph.lib: + layer.width = ufo_glyph.lib[GLYPHLIB_PREFIX + 'originalWidth'] + # TODO: check for customParameter 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, 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 background: + if not layer.hasBackground: return + background = layer.background + + # FIXME: (jany) move most of this to layers.py if glyph.layer.name != 'public.default': layer_name = glyph.layer.name + '.background' else: @@ -119,57 +240,28 @@ def to_ufo_glyph_background(self, glyph, background): 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) + + 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_index(self, color): + # color is a defcon Color + index, _ = min( + enumerate(GLYPHS_COLORS), + key=lambda _, glyphs_color: _rgb_distance(color, Color(glyphs_color))) + return index + # TODO: (jany) remove color approximation, actually it's possible to store + # arbitrary colors in Glyphs - 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 + +def _rgb_distance(c1, c2): + # https://en.wikipedia.org/wiki/Color_difference + rmean = float(c1.r+c2.r) / 2 + dr = c1.r - c2.r + dg = c1.g - c2.g + db = c1.b - c2.b + return (2 + rmean)*dr*dr + 4*dg*dg + (3 - rmean)*db*db diff --git a/Lib/glyphsLib/builder/guidelines.py b/Lib/glyphsLib/builder/guidelines.py index 58a3c29cc..77ba6e524 100644 --- a/Lib/glyphsLib/builder/guidelines.py +++ b/Lib/glyphsLib/builder/guidelines.py @@ -15,6 +15,10 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) +from glyphsLib.types import Point + +LOCKED_NAME_SUFFIX = ' [locked]' + def to_ufo_guidelines(self, ufo_obj, glyphs_obj): """Set guidelines.""" @@ -23,14 +27,30 @@ def to_ufo_guidelines(self, ufo_obj, glyphs_obj): return new_guidelines = [] for guideline in guidelines: - x, y = guideline.position angle = guideline.angle - new_guideline = {'x': x, 'y': y, 'angle': (360 - angle) % 360} + angle = (360 - angle) % 360 + name = guideline.name + if guideline.locked: + name += LOCKED_NAME_SUFFIX + new_guideline = {'x': x, 'y': y, 'angle': angle} + 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 + if name is not None and name.endswith(LOCKED_NAME_SUFFIX): + name = name[:-len(LOCKED_NAME_SUFFIX)] + new_guideline.locked = True + new_guideline.name = name + new_guideline.position = Point(guideline.x, guideline.y) + new_guideline.angle = (360 - guideline.angle) % 360 + glyphs_obj.guides.append(new_guideline) 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..cb1302f3a --- /dev/null +++ b/Lib/glyphsLib/builder/instances.py @@ -0,0 +1,414 @@ +# 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.util import build_ufo_path +from .constants import (GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX, + MASTER_CUSTOM_PARAM_PREFIX) +from .names import build_stylemap_names + +EXPORT_KEY = GLYPHS_PREFIX + 'export' +WIDTH_KEY = GLYPHS_PREFIX + 'width' +WEIGHT_KEY = GLYPHS_PREFIX + 'weight' +WEIGHT_CLASS_KEY = GLYPHS_PREFIX + 'weightClass' +WIDTH_CLASS_KEY = GLYPHS_PREFIX + 'widthClass' +MANUAL_INTERPOLATION_KEY = GLYPHS_PREFIX + 'manualInterpolation' +INSTANCE_INTERPOLATIONS_KEY = GLYPHS_PREFIX + 'intanceInterpolations' + + +def to_ufo_instances(self): + """Write instance data from self.font to self.designspace.""" + + # base_family = masters[0].info.familyName + # assert all(m.info.familyName == base_family for m in masters), \ + # 'Masters must all have same family' + + # for font in masters: + # write_ufo(font, master_dir) + + # needed so that added masters and instances have correct relative paths + # tmp_path = os.path.join(master_dir, 'tmp.designspace') + # writer = DesignSpaceDocumentWriter(tmp_path) + + # instances = list(filter(is_instance_active, instance_data.get('data', []))) + ufo_masters = list(self.masters) + varfont_origin = _get_varfont_origin(ufo_masters) + regular = _find_regular_master(ufo_masters, regularName=varfont_origin) + _to_ufo_designspace_axes(self, regular) + _to_ufo_designspace_sources(self, regular) + + for instance in self.font.instances: + _to_ufo_designspace_instance(self, instance) + + +def _get_varfont_origin(masters): + # the 'Variation Font Origin' is a font-wide custom parameter, thus it is + # shared by all the master ufos; here we just get it from the first one + varfont_origin_key = "Variation Font Origin" + return masters[0].lib.get(FONT_CUSTOM_PARAM_PREFIX + varfont_origin_key) + + +def _find_regular_master(masters, regularName=None): + """Find the "regular" master among the master UFOs. + + Tries to find the master with the passed 'regularName'. + If there is no such master or if regularName is None, + tries to find a base style shared between all masters + (defaulting to "Regular"), and then tries to find a master + with that style name. If there is no master with that name, + returns the first master in the list. + """ + assert len(masters) > 0 + if regularName is not None: + for font in masters: + if font.info.styleName == regularName: + return font + base_style = find_base_style(masters) + if not base_style: + base_style = 'Regular' + for font in masters: + if font.info.styleName == base_style: + return font + return masters[0] + + +def find_base_style(masters): + """Find a base style shared between all masters. + Return empty string if none is found. + """ + base_style = masters[0].info.styleName.split() + for font in masters: + style = font.info.styleName.split() + base_style = [s for s in style if s in base_style] + base_style = ' '.join(base_style) + return base_style + + +# Glyphs.app's default values for the masters' {weight,width,custom}Value +# and for the instances' interpolation{Weight,Width,Custom} properties. +# When these values are set, they are omitted from the .glyphs source file. +# FIXME: (jany) This behaviour should be in classes.py +DEFAULT_LOCS = { + 'weight': 100, + 'width': 100, + 'custom': 0, + 'custom1': 0, + 'custom2': 0, + 'custom3': 0, +} + +WEIGHT_CODES = { + 'Thin': 250, + 'ExtraLight': 250, + 'UltraLight': 250, + 'Light': 300, + None: 400, # default value normally omitted in source + 'Normal': 400, + 'Regular': 400, + 'Medium': 500, + 'DemiBold': 600, + 'SemiBold': 600, + 'Bold': 700, + 'UltraBold': 800, + 'ExtraBold': 800, + 'Black': 900, + 'Heavy': 900, +} + +WIDTH_CODES = { + 'Ultra Condensed': 1, + 'Extra Condensed': 2, + 'Condensed': 3, + 'SemiCondensed': 4, + None: 5, # default value normally omitted in source + 'Medium (normal)': 5, + 'Semi Expanded': 6, + 'Expanded': 7, + 'Extra Expanded': 8, + 'Ultra Expanded': 9, +} + + +def _to_ufo_designspace_axes(self, regular_master): + # According to Georg Seifert, Glyphs 3 will have a better model + # for describing variation axes. The plan is to store the axis + # information globally in the Glyphs file. In addition to actual + # variation axes, this new structure will probably also contain + # stylistic information for design axes that are not variable but + # should still be stored into the OpenType STAT table. + # + # We currently take the minima and maxima from the instances, and + # have hard-coded the default value for each axis. We could be + # smarter: for the minima and maxima, we could look at the masters + # (whose locations are only stored in interpolation space, not in + # user space) and reverse-interpolate these locations to user space. + # Likewise, we could try to infer the default axis value from the + # masters. But it's probably not worth this effort, given that + # the upcoming version of Glyphs is going to store explicit + # axis desriptions in its file format. + + # FIXME: (jany) find interpolation data in GSFontMaster rather than in UFO? + # It would allow to drop the DEFAULT_LOCS dictionary + masters = list(self.masters) + instances = self.font.instances + + for name, tag, userLocParam, defaultUserLoc, codes in ( + ('weight', 'wght', 'weightClass', 400, WEIGHT_CODES), + ('width', 'wdth', 'widthClass', 100, WIDTH_CODES), + ('custom', 'XXXX', None, 0, None), + ('custom1', 'XXX1', None, 0, None), + ('custom2', 'XXX2', None, 0, None), + ('custom3', 'XXX3', None, 0, None)): + key = MASTER_CUSTOM_PARAM_PREFIX + name + 'Value' + if name.startswith('custom'): + key = MASTER_CUSTOM_PARAM_PREFIX + 'customValue' + name[len('custom'):] + if any(key in master.lib for master in masters): + axis = self.designspace.newAxisDescriptor() + axis.tag = tag + axis.name = name + regularInterpolLoc = regular_master.lib.get(key, DEFAULT_LOCS[name]) + regularUserLoc = defaultUserLoc + + labelName = name.title() + if name.startswith('custom'): + name_key = MASTER_CUSTOM_PARAM_PREFIX + 'customName' + name[len('custom'):] + for master in masters: + if name_key in master.lib: + labelName = master.lib[name_key] + break + axis.labelNames = {"en": labelName} + + interpolLocKey = name + 'Value' + if name.startswith('custom'): + interpolLocKey = 'customValue' + name[len('custom'):] + mapping = [] + for instance in instances: + interpolLoc = getattr(instance, interpolLocKey) + userLoc = interpolLoc + if userLocParam in instance.customParameters: + userLoc = float(instance.customParameters[userLocParam]) + elif (codes is not None and getattr(instance, name) and + getattr(instance, name) in codes): + userLoc = codes[getattr(instance, name)] + mapping.append((userLoc, interpolLoc)) + if interpolLoc == regularInterpolLoc: + regularUserLoc = userLoc + mapping = sorted(set(mapping)) # avoid duplicates + if mapping: + axis.minimum = min([userLoc for userLoc, _ in mapping]) + axis.maximum = max([userLoc for userLoc, _ in mapping]) + axis.default = min(axis.maximum, max(axis.minimum, regularUserLoc)) # clamp + else: + axis.minimum = axis.maximum = axis.default = defaultUserLoc + axis.map = mapping + self.designspace.addAxis(axis) + + +def _to_ufo_designspace_sources(self, regular): + """Add master UFOs to the designspace document.""" + # FIXME: (jany) maybe read data from the GSFontMasters directly? + for font in self.masters: + source = self.designspace.newSourceDescriptor() + source.font = font + source.familyName = font.info.familyName + source.styleName = font.info.styleName + source.name = '%s %s' % (source.familyName, source.styleName) + source.filename = build_ufo_path('.', source.familyName, + source.styleName) + + # MutatorMath.DesignSpaceDocumentWriter iterates over the location + # dictionary, which is non-deterministic so it can cause test failures. + # We therefore use an OrderedDict to which we insert in axis order. + # Since glyphsLib will switch to DesignSpaceDocument once that is + # integrated into fonttools, it's not worth fixing upstream. + # https://github.com/googlei18n/glyphsLib/issues/165 + # FIXME: (jany) still needed? + location = OrderedDict() + for axis in self.designspace.axes: + value_key = axis.name + 'Value' + if axis.name.startswith('custom'): + # FIXME: (jany) this is getting boring + value_key = 'customValue' + axis.name[len('custom'):] + location[axis.name] = font.lib.get( + MASTER_CUSTOM_PARAM_PREFIX + value_key, DEFAULT_LOCS[axis.name]) + source.location = location + if font is regular: + source.copyLib = True + source.copyInfo = True + source.copyGroups = True + source.copyFeatures = True + self.designspace.addSource(source) + + +def _to_ufo_designspace_instance(self, instance): + ufo_instance = self.designspace.newInstanceDescriptor() + 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': + ufo_instance.filename = value + '.ufo' + if ufo_instance.familyName is None: + ufo_instance.familyName = self.family_name + + ufo_instance.styleName = instance.name + if not ufo_instance.filename: + ufo_instance.filename = build_ufo_path('.', ufo_instance.familyName, + ufo_instance.styleName) + # ofiles.append((ufo_path, instance)) + # MutatorMath.DesignSpaceDocumentWriter iterates over the location + # dictionary, which is non-deterministic so it can cause test failures. + # We therefore use an OrderedDict to which we insert in axis order. + # Since glyphsLib will switch to DesignSpaceDocument once that is + # integrated into fonttools, it's not worth fixing upstream. + # https://github.com/googlei18n/glyphsLib/issues/165 + # FIXME: (jany) still needed? + location = OrderedDict() + # FIXME: (jany) make a function for iterating axes and the related properties? + for axis in self.designspace.axes: + value_key = axis.name + 'Value' + if axis.name.startswith('custom'): + value_key = 'customValue' + axis.name[len('custom'):] + location[axis.name] = getattr(instance, value_key) + + ufo_instance.location = location + + ufo_instance.styleMapFamilyName, ufo_instance.styleMapStyleName = \ + build_stylemap_names( + family_name=ufo_instance.familyName, + style_name=ufo_instance.styleName, + is_bold=instance.isBold, + is_italic=instance.isItalic, + linked_style=instance.linkStyle, + ) + + ufo_instance.name = ' '.join((ufo_instance.familyName, + ufo_instance.styleName)) + + ufo_instance.lib[EXPORT_KEY] = instance.active + ufo_instance.lib[WEIGHT_KEY] = instance.weight + ufo_instance.lib[WIDTH_KEY] = instance.width + + if 'weightClass' in instance.customParameters: + ufo_instance.lib[WEIGHT_CLASS_KEY] = instance.customParameters['weightClass'] + if 'widthClass' in instance.customParameters: + ufo_instance.lib[WIDTH_CLASS_KEY] = instance.customParameters['widthClass'] + + ufo_instance.lib[INSTANCE_INTERPOLATIONS_KEY] = instance.instanceInterpolations + ufo_instance.lib[MANUAL_INTERPOLATION_KEY] = instance.manualInterpolation + + # TODO: put the userData/customParameters in lib + self.designspace.addInstance(ufo_instance) + + +def to_glyphs_instances(self): + if self.designspace is None: + return + + for ufo_instance in self.designspace.instances: + instance = self.glyphs_module.GSInstance() + + # TODO: lots of stuff! + # active + # name + # weight + # width + # weightValue + # widthValue + # customValue + # isItalic + # isBold + # linkStyle + # familyName + # preferredFamily + # preferredSubfamilyName + # windowsFamily + # windowsStyle + # windowsLinkedToStyle + # fontName + # fullName + # customParameters + # instanceInterpolations + # manualInterpolation + + 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 + + try: + instance.weight = ufo_instance.lib[WEIGHT_KEY] + except KeyError: + # FIXME: what now + pass + + try: + instance.width = ufo_instance.lib[WIDTH_KEY] + except KeyError: + # FIXME: what now + pass + + for axis in [ + 'weight', 'width', 'custom', 'custom1', 'custom2', 'custom3' + ]: + # Retrieve the interpolation location + try: + loc = ufo_instance.location[axis] + value_key = axis + 'Value' + if axis.startswith('custom'): + value_key = 'customValue' + axis[len('custom'):] + setattr(instance, value_key, loc) + except KeyError: + # FIXME: (jany) what now? + pass + + for axis, lib_key in [('weight', WEIGHT_CLASS_KEY), + ('width', WIDTH_CLASS_KEY)]: + # Retrieve the user location (weightClass/widthClass) + try: + # First way: for round-tripped data, read the glyphsLib key + instance.customParameters[axis + 'Class'] = ufo_instance.lib[ + lib_key] + except KeyError: + # Second way: for UFOs/designspace of other origins, read the + # mapping backwards and check that the user location matches + # the instance's weight/width. If not, set the the custom param. + # TODO: (jany) + pass + + 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 + + self.font.instances.append(instance) diff --git a/Lib/glyphsLib/interpolation.py b/Lib/glyphsLib/builder/interpolation.py similarity index 95% rename from Lib/glyphsLib/interpolation.py rename to Lib/glyphsLib/builder/interpolation.py index cf9b53759..611a79c1d 100644 --- a/Lib/glyphsLib/interpolation.py +++ b/Lib/glyphsLib/builder/interpolation.py @@ -20,9 +20,10 @@ import os import xml.etree.ElementTree as etree -from glyphsLib.builder.custom_params import set_custom_params -from glyphsLib.builder.names import build_stylemap_names -from glyphsLib.builder.constants import GLYPHS_PREFIX +from .builders import UFOBuilder +from .custom_params import to_ufo_custom_params +from .names import build_stylemap_names +from .constants import GLYPHS_PREFIX from glyphsLib.util import build_ufo_path, write_ufo, clean_ufo @@ -35,6 +36,7 @@ # Glyphs.app's default values for the masters' {weight,width,custom}Value # and for the instances' interpolation{Weight,Width,Custom} properties. # When these values are set, they are omitted from the .glyphs source file. +# FIXME: (jany) This behaviour should be in classes.py DEFAULT_LOCS = { 'weight': 100, 'width': 100, @@ -326,6 +328,11 @@ def add_instances_to_writer(writer, family_name, axes, instances, out_dir): location=location, familyName=familyName, styleName=styleName, + # FIXME: (jany) must provide a postscriptFontName or else cannot + # read back the instance element using DesignspaceDocumentReader + # becauses its .instances are in dictionary + # {postscriptFontName: instance} + # postScriptFontName=postScriptFontName or familyName + styleName, postScriptFontName=postScriptFontName, styleMapFamilyName=styleMapFamilyName, styleMapStyleName=styleMapStyleName, @@ -370,14 +377,17 @@ def apply_instance_data(instance_data): Returns: List of opened and updated instance UFOs. """ - from defcon import Font + import defcon instance_ufos = [] for path, data in instance_data: - ufo = Font(path) + ufo = defcon.Font(path) set_weight_class(ufo, data) set_width_class(ufo, data) - set_custom_params(ufo, data=data) + self = UFOBuilder(instance_data, defcon) + to_ufo_custom_params(self, ufo, data) ufo.save() instance_ufos.append(ufo) return instance_ufos + + diff --git a/Lib/glyphsLib/builder/kerning.py b/Lib/glyphsLib/builder/kerning.py index 4c3f85b45..8c2cfd019 100644 --- a/Lib/glyphsLib/builder/kerning.py +++ b/Lib/glyphsLib/builder/kerning.py @@ -17,9 +17,16 @@ import logging import re +from collections import defaultdict logger = logging.getLogger(__name__) +GROUP_KEYS = { + '1': 'rightKerningGroup', + '2': 'leftKerningGroup'} + +UFO_KERN_GROUP_PATTERN = re.compile('^public\\.kern([12])\\.(.*)$') + def to_ufo_kerning(self, ufo, kerning_data): """Add .glyphs kerning to an UFO.""" @@ -34,7 +41,6 @@ def to_ufo_kerning(self, ufo, kerning_data): left = 'public.kern1.%s' % match.group(1) if left not in ufo.groups: logger.warn(warning_msg % left) - continue for right, kerning_val in pairs.items(): match = re.match(r'@MMK_R_(.+)', right) right_is_class = bool(match) @@ -42,7 +48,6 @@ def to_ufo_kerning(self, ufo, kerning_data): right = 'public.kern2.%s' % match.group(1) if right not in ufo.groups: logger.warn(warning_msg % right) - continue if left_is_class != right_is_class: if left_is_class: pair = (left, right, True) @@ -56,6 +61,19 @@ def to_ufo_kerning(self, ufo, kerning_data): _remove_rule_if_conflict(ufo, seen, classname, glyph, is_left_class) +def to_glyphs_kerning(self, ufo, master): + """Add UFO kerning to GSFontMaster.""" + for (left, right), value in ufo.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? + + def _remove_rule_if_conflict(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. @@ -71,8 +89,8 @@ def _remove_rule_if_conflict(ufo, seen, classname, glyph, is_left_class): 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): + existing_rule[-1] != val and + pair not in ufo.kerning): logger.warn( 'Conflicting kerning rules found in %s master for glyph pair ' '"%s, %s" (%s and %s), removing pair from latter rule' % @@ -92,10 +110,7 @@ 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(): + for side, group_key in GROUP_KEYS.items(): group = getattr(glyph_data, group_key) if group is None or len(group) == 0: continue @@ -103,8 +118,33 @@ def to_ufo_glyph_groups(self, kerning_groups, glyph_data): kerning_groups[group] = kerning_groups.get(group, []) + [glyph_name] +def to_glyphs_glyph_groups(self, kerning_groups, glyph): + """Write kerning groups to the GSGlyph. + Uses the ouput of to_glyphs_kerning_groups. + """ + for group_key, group_name in kerning_groups.items(): + setattr(glyph, group_key, group_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_groups(self, ufo): + """Extract all kerning group information from UFO. + Return a dict {glyph name: dict {rightKerningGroup: leftKerningGroup: }} + """ + result = defaultdict(dict) + for group, members in ufo.groups.items(): + match = UFO_KERN_GROUP_PATTERN.match(group) + if not match: + continue + side = match.group(1) + group_name = match.group(2) + for glyph_name in members: + result[glyph_name][GROUP_KEYS[side]] = group_name + + return result diff --git a/Lib/glyphsLib/builder/layers.py b/Lib/glyphsLib/builder/layers.py new file mode 100644 index 000000000..a028b0645 --- /dev/null +++ b/Lib/glyphsLib/builder/layers.py @@ -0,0 +1,80 @@ +# 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_TEMP_USER_DATA_KEY = '__layerOrder' + + +def to_glyphs_layer(self, ufo_layer, glyph, master): + if ufo_layer.name == 'public.default': # TODO: (jany) constant + if master.id not in glyph.layers: + glyph.layers[master.id] = self.glyphs_module.GSLayer() + layer = glyph.layers[master.id] + layer.layerId = master.id + layer.name = master.name + elif ufo_layer.name == 'public.background': + master_layer = glyph.layers[master.id] + 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 = GLYPHS_PREFIX + 'layerOrderInGlyph.' + 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 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/names.py b/Lib/glyphsLib/builder/names.py index 18063d377..d481b33cc 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -96,3 +96,13 @@ 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): + # FIXME: (jany) dubious, the ufo family name is not what was in Glyphs but + # what was given as an argument to to_ufo... why? + self.font.familyName = ufo.info.familyName + + +def to_glyphs_master_names(self, ufo, master): + pass diff --git a/Lib/glyphsLib/builder/paths.py b/Lib/glyphsLib/builder/paths.py index 37a697b31..4b93b9e09 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,37 @@ 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) + if not contour.open: + path.closed = True + 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 + return node_type diff --git a/Lib/glyphsLib/builder/user_data.py b/Lib/glyphsLib/builder/user_data.py index f90659bf4..190e49014 100644 --- a/Lib/glyphsLib/builder/user_data.py +++ b/Lib/glyphsLib/builder/user_data.py @@ -15,15 +15,22 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -from .constants import GLYPHS_PREFIX +from .constants import GLYPHS_PREFIX, PUBLIC_PREFIX MASTER_USER_DATA_KEY = GLYPHS_PREFIX + 'fontMaster.userData' +LAYER_USER_DATA_KEY = GLYPHS_PREFIX + 'layer.userData' +GLYPH_USER_DATA_KEY = GLYPHS_PREFIX + 'glyph.userData' +NODE_USER_DATA_KEY = GLYPHS_PREFIX + 'node.userData' 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(): + # FIXME: (jany) Should put a Glyphs prefix? + # FIXME: (jany) At least identify which stuff we have put in lib during + # the Glyphs->UFO so that we don't take it back into userData in + # the other direction. ufo.lib[key] = user_data[key] @@ -37,11 +44,62 @@ def to_ufo_master_user_data(self, ufo, master): ufo.lib[MASTER_USER_DATA_KEY] = data +def to_ufo_glyph_user_data(self, ufo_glyph, glyph): + user_data = glyph.userData + if user_data: + ufo_glyph.lib[GLYPH_USER_DATA_KEY] = dict(user_data) + + +def to_ufo_layer_user_data(self, ufo_glyph, layer): + user_data = layer.userData + if user_data: + key = LAYER_USER_DATA_KEY + '.' + layer.layerId + ufo_glyph.lib[key] = dict(user_data) + + +def to_ufo_node_user_data(self, ufo_glyph, node): + user_data = node.userData + if user_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(self, ufo): """Set the GSFont userData from the UFO family-wide user data.""" - pass + target_user_data = self.font.userData + for key, value in ufo.lib.items(): + if _user_data_was_originally_there_family_wide(key): + target_user_data[key] = value def to_glyphs_master_user_data(self, ufo, master): """Set the GSFontMaster userData from the UFO master-specific user data.""" - pass + if MASTER_USER_DATA_KEY not in ufo.lib: + return + user_data = ufo.lib[MASTER_USER_DATA_KEY] + if user_data: + master.userData = user_data + + +def to_glyphs_glyph_user_data(self, ufo_glyph, glyph): + if GLYPH_USER_DATA_KEY in ufo_glyph.lib: + glyph.userData = ufo_glyph.lib[GLYPH_USER_DATA_KEY] + + +def to_glyphs_layer_user_data(self, ufo_glyph, layer): + key = LAYER_USER_DATA_KEY + '.' + layer.layerId + if key in ufo_glyph.lib: + layer.userData = ufo_glyph.lib[key] + + +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_was_originally_there_family_wide(key): + # FIXME: (jany) Identify better which keys must be brought back? + 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..f4d70fe5a 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) 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', @@ -148,11 +155,11 @@ 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 +227,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 +246,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 +284,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 +373,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 ] @@ -482,20 +496,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 +515,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 +603,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 +647,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 +687,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 +726,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 +738,7 @@ def _ensureMasterLayers(self): def plistArray(self): return list(self._owner._layers.values()) + class LayerAnchorsProxy(Proxy): def __getitem__(self, key): @@ -909,7 +939,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): @@ -1093,7 +1123,7 @@ def __init__(self, pos=0, size=20): 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 +1145,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,7 +1163,6 @@ def __repr__(self): (self.__class__.__name__, self.position.x, self.position.y, self.angle) - @property def parent(self): return self._parent @@ -1170,11 +1199,14 @@ class GSFontMaster(GSBase): "xHeight": float, } _defaultsForName = { + "weight": "Regular", + "width": "Regular", "weightValue": 100.0, "widthValue": 100.0, "xHeight": 500, "capHeight": 700, "ascender": 800, + "descender": -200, } _wrapperKeysTranslate = { "guideLines": "guides", @@ -1218,8 +1250,6 @@ def __init__(self): self.font = None self._name = None self._customParameters = [] - self._weight = "Regular" - self._width = "Regular" self.italicAngle = 0.0 self._userData = None for number in ('', '1', '2', '3'): @@ -1235,7 +1265,7 @@ def shouldWriteValueForKey(self, key): if getattr(self, key) == "Regular": return False return True - if key in ("xHeight", "capHeight", "ascender"): + if key in ("xHeight", "capHeight", "ascender", "descender"): # Always write those values return True if key == "name": @@ -1248,7 +1278,7 @@ def shouldWriteValueForKey(self, key): def name(self): name = self.customParameters["Master Name"] if name is None: - names = [self._weight, self._width] + 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 @@ -1276,26 +1306,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 +1320,7 @@ class GSNode(GSBase): def __init__(self, position=(0, 0), nodetype=LINE, smooth=False, name=None): - self.position = point(position[0], position[1]) + self.position = Point(position[0], position[1]) self.type = nodetype self.smooth = smooth self._parent = None @@ -1349,7 +1359,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 +1459,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 = { @@ -1546,7 +1567,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 +1618,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 +1636,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 +1732,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 +1750,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 +1759,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 +1776,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 +1812,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 +1850,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 +1882,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): @@ -1884,22 +1910,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 +1946,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 +1985,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 +2002,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 +2014,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 +2026,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 +2038,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 +2050,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 +2062,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 +2074,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 +2133,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 +2155,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,20 +2204,27 @@ 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 @@ -2207,17 +2236,14 @@ def __init__(self): 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): @@ -2308,14 +2334,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 +2363,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 +2404,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 +2425,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,6 +2445,7 @@ class GSLayer(GSBase): "widthMetricsKey": unicode, } _defaultsForName = { + "width": 0, # FIXME: (jany) check in glyphs "weight": 600, "leftMetricsKey": None, "rightMetricsKey": None, @@ -2424,6 +2453,7 @@ class GSLayer(GSBase): } _wrapperKeysTranslate = { "guideLines": "guides", + "background": "_background", } _keyOrder = ( "anchors", @@ -2450,6 +2480,7 @@ class GSLayer(GSBase): def __init__(self): super(GSLayer, self).__init__() + self.parent = None self._anchors = [] self._hints = [] self._annotations = [] @@ -2458,6 +2489,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 +2510,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: @@ -2489,8 +2545,6 @@ def shouldWriteValueForKey(self, key): 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 +2626,53 @@ 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 + +GSLayer._classesForName['background'] = GSBackgroundLayer class GSGlyph(GSBase): @@ -2580,10 +2680,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, @@ -2722,7 +2822,7 @@ class GSFont(GSBase): "classes": GSClass, "copyright": unicode, "customParameters": GSCustomParameter, - "date": glyphs_datetime, + "date": parse_datetime, "designer": unicode, "designerURL": unicode, "disablesAutomaticAlignment": bool, @@ -2907,3 +3007,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 EMPTY_KERNING_VALUE + try: + return self._kerning[fontMasterId][leftKey][rightKey] + except KeyError: + return 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/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py new file mode 100644 index 000000000..24de39744 --- /dev/null +++ b/Lib/glyphsLib/designSpaceDocument.py @@ -0,0 +1,2358 @@ +# -*- coding: utf-8 -*- + + +# FIXME: (jany) copy-pasted from https://github.com/LettError/designSpaceDocument +# TODO: move to fontTools https://github.com/LettError/designSpaceDocument/issues/28 +# https://github.com/fonttools/fonttools/issues/911 + + + + + + +from __future__ import print_function, division, absolute_import +import collections +import logging +import os +import posixpath +import json # FIXME: (jany) that's for lib, should use plist xml I guess +import xml.etree.ElementTree as ET +from mutatorMath.objects.location import biasFromLocations, Location + +""" + designSpaceDocument + + - read and write designspace files + - axes must be defined. + - warpmap is stored in its axis element +""" + +__all__ = [ + 'DesignSpaceDocumentError', 'DesignSpaceDocument', 'SourceDescriptor', + 'InstanceDescriptor', 'AxisDescriptor', 'RuleDescriptor', 'BaseDocReader', + 'BaseDocWriter' +] + + +class DesignSpaceDocumentError(Exception): + def __init__(self, msg, obj=None): + self.msg = msg + self.obj = obj + + def __str__(self): + return repr(self.msg) + repr(self.obj) + + +def _indent(elem, whitespace=" ", level=0): + # taken from http://effbot.org/zone/element-lib.htm#prettyprint + i = "\n" + level * whitespace + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + whitespace + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + _indent(elem, whitespace, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + + +class SimpleDescriptor(object): + """ Containers for a bunch of attributes""" + def compare(self, other): + # test if this object contains the same data as the other + for attr in self._attrs: + try: + assert(getattr(self, attr) == getattr(other, attr)) + except AssertionError: + print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr)) + + +class SourceDescriptor(SimpleDescriptor): + """Simple container for data related to the source""" + flavor = "source" + _attrs = ['filename', 'path', 'name', + 'location', 'copyLib', + 'copyGroups', 'copyFeatures', + 'muteKerning', 'muteInfo', + 'mutedGlyphNames', + 'familyName', 'styleName'] + + def __init__(self): + self.filename = None # the original path as found in the document + self.path = None # the absolute path, calculated from filename + self.name = None + self.location = None + self.copyLib = False + self.copyInfo = False + self.copyGroups = False + self.copyFeatures = False + self.muteKerning = False + self.muteInfo = False + self.mutedGlyphNames = [] + self.familyName = None + self.styleName = None + + +class RuleDescriptor(SimpleDescriptor): + """ + + + + + + + + + + + Discussion: + use axis names rather than tags - then we can evaluate the rule without having to look up the axes. + remove the subs from the rule. + remove 'enabled' attr form rule + + + """ + _attrs = ['name', 'conditions', 'subs'] # what do we need here + def __init__(self): + self.name = None + self.conditions = [] # list of dict(tag='aaaa', minimum=0, maximum=1000) + self.subs = [] # list of substitutions stored as tuples of glyphnames ("a", "a.alt") + +def evaluateRule(rule, location): + """ Test if rule is True at location.maximum + If a condition has no minimum, check for < maximum. + If a condition has no maximum, check for > minimum. + """ + for cd in rule.conditions: + if not cd['name'] in location: + continue + if cd.get('minimum') is None: + if not location[cd['name']] <= cd['maximum']: + return False + elif cd.get('maximum') is None: + if not cd['minimum'] <= location[cd['name']]: + return False + else: + if not cd['minimum'] <= location[cd['name']] <= cd['maximum']: + return False + return True + +def processRules(rules, location, glyphNames): + """ Apply these rules at this location to these glyphnames.minimum + - rule order matters + """ + newNames = [] + for rule in rules: + if evaluateRule(rule, location): + for name in glyphNames: + swap = False + for a, b in rule.subs: + if name == a: + swap = True + break + if swap: + newNames.append(b) + else: + newNames.append(name) + glyphNames = newNames + newNames = [] + return glyphNames + + + + +class InstanceDescriptor(SimpleDescriptor): + """Simple container for data related to the instance""" + flavor = "instance" + _defaultLanguageCode = "en" + _attrs = [ 'path', + 'name', + 'location', + 'familyName', + 'styleName', + 'postScriptFontName', + 'styleMapFamilyName', + 'styleMapStyleName', + 'kerning', + 'info', + 'lib'] + + def __init__(self): + self.filename = None # the original path as found in the document + self.path = None # the absolute path, calculated from filename + self.name = None + self.location = None + self.familyName = None + self.styleName = None + self.postScriptFontName = None + self.styleMapFamilyName = None + self.styleMapStyleName = None + self.localisedStyleName = {} + self.localisedFamilyName = {} + self.localisedStyleMapStyleName = {} + self.localisedStyleMapFamilyName = {} + self.glyphs = {} + self.mutedGlyphNames = [] + self.kerning = True + self.info = True + self.lib = {} + + def setStyleName(self, styleName, languageCode="en"): + self.localisedStyleName[languageCode] = styleName + def getStyleName(self, languageCode="en"): + return self.localisedStyleName.get(languageCode) + + def setFamilyName(self, familyName, languageCode="en"): + self.localisedFamilyName[languageCode] = familyName + def getFamilyName(self, languageCode="en"): + return self.localisedFamilyName.get(languageCode) + + def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"): + self.localisedStyleMapStyleName[languageCode] = styleMapStyleName + def getStyleMapStyleName(self, languageCode="en"): + return self.localisedStyleMapStyleName.get(languageCode) + + def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"): + self.localisedStyleMapFamilyName[languageCode] = styleMapFamilyName + def getStyleMapFamilyName(self, languageCode="en"): + return self.localisedStyleMapFamilyName.get(languageCode) + +def tagForAxisName(name): + # try to find or make a tag name for this axis name + names = { + 'weight': ('wght', dict(en = 'Weight')), + 'width': ('wdth', dict(en = 'Width')), + 'optical': ('opsz', dict(en = 'Optical Size')), + 'slant': ('slnt', dict(en = 'Slant')), + 'italic': ('ital', dict(en = 'Italic')), + } + if name.lower() in names: + return names[name.lower()] + if len(name) < 4: + tag = name + "*"*(4-len(name)) + else: + tag = name[:4] + return tag, dict(en = name) + + +class AxisDescriptor(SimpleDescriptor): + """ Simple container for the axis data + Add more localisations? + """ + flavor = "axis" + _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map'] + + def __init__(self): + self.tag = None # opentype tag for this axis + self.name = None # name of the axis used in locations + self.labelNames = {} # names for UI purposes, if this is not a standard axis, + self.minimum = None + self.maximum = None + self.default = None + self.hidden = False + self.map = [] + + def serialize(self): + # output to a dict, used in testing + d = dict(tag = self.tag, + name = self.name, + labelNames = self.labelNames, + maximum = self.maximum, + minimum = self.minimum, + default = self.default, + hidden = self.hidden, + map = self.map, + ) + return d + + +class BaseDocWriter(object): + _whiteSpace = " " + ruleDescriptorClass = RuleDescriptor + axisDescriptorClass = AxisDescriptor + sourceDescriptorClass = SourceDescriptor + instanceDescriptorClass = InstanceDescriptor + + @classmethod + def getAxisDecriptor(cls, document): + return cls.axisDescriptorClass() + + @classmethod + def getSourceDescriptor(cls, document): + return cls.sourceDescriptorClass() + + @classmethod + def getInstanceDescriptor(cls, document): + return cls.instanceDescriptorClass() + + @classmethod + def getRuleDescriptor(cls, document): + return cls.ruleDescriptorClass() + + def __init__(self, documentPath, documentObject): + self.path = documentPath + self.documentObject = documentObject + self.toolVersion = 3 + self.root = ET.Element("designspace") + self.root.attrib['format'] = "%d" % self.toolVersion + #self.root.append(ET.Element("axes")) + #self.root.append(ET.Element("rules")) + #self.root.append(ET.Element("sources")) + #self.root.append(ET.Element("instances")) + self.axes = [] + self.rules = [] + + def newDefaultLocation(self): + loc = collections.OrderedDict() + for axisDescriptor in self.axes: + loc[axisDescriptor.name] = axisDescriptor.default + return loc + + def write(self, pretty=True): + if self.documentObject.axes: + self.root.append(ET.Element("axes")) + for axisObject in self.documentObject.axes: + self._addAxis(axisObject) + + if self.documentObject.rules: + self.root.append(ET.Element("rules")) + for ruleObject in self.documentObject.rules: + self._addRule(ruleObject) + + if self.documentObject.sources: + self.root.append(ET.Element("sources")) + for sourceObject in self.documentObject.sources: + self._addSource(sourceObject) + + if self.documentObject.instances: + self.root.append(ET.Element("instances")) + for instanceObject in self.documentObject.instances: + self._addInstance(instanceObject) + if pretty: + _indent(self.root, whitespace=self._whiteSpace) + tree = ET.ElementTree(self.root) + tree.write(self.path, encoding="utf-8", method='xml', xml_declaration=True) + + def _makeLocationElement(self, locationObject, name=None): + """ Convert Location dict to a locationElement.""" + locElement = ET.Element("location") + if name is not None: + locElement.attrib['name'] = name + defaultLoc = self.newDefaultLocation() + # Without OrderedDict, output XML would be non-deterministic. + # https://github.com/LettError/designSpaceDocument/issues/10 + validatedLocation = collections.OrderedDict() + for axisName, axisValue in defaultLoc.items(): + # update the location dict with missing default axis values + validatedLocation[axisName] = locationObject.get(axisName, axisValue) + for dimensionName, dimensionValue in validatedLocation.items(): + dimElement = ET.Element('dimension') + dimElement.attrib['name'] = dimensionName + if type(dimensionValue) == tuple: + dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue[0]) + dimElement.attrib['yvalue'] = self.intOrFloat(dimensionValue[1]) + else: + dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue) + locElement.append(dimElement) + return locElement, validatedLocation + + def intOrFloat(self, num): + if int(num) == num: + return "%d" % num + return "%f" % num + + def _addRule(self, ruleObject): + # if none of the conditions have minimum or maximum values, do not add the rule. + self.rules.append(ruleObject) + ruleElement = ET.Element('rule') + ruleElement.attrib['name'] = ruleObject.name + for cond in ruleObject.conditions: + if cond.get('minimum') is None and cond.get('maximum') is None: + # neither is defined, don't add this condition + continue + conditionElement = ET.Element('condition') + conditionElement.attrib['name'] = cond.get('name') + if cond.get('minimum') is not None: + conditionElement.attrib['minimum'] = self.intOrFloat(cond.get('minimum')) + if cond.get('maximum') is not None: + conditionElement.attrib['maximum'] = self.intOrFloat(cond.get('maximum')) + ruleElement.append(conditionElement) + for sub in ruleObject.subs: + # skip empty subs + if sub[0] == '' and sub[1] == '': + continue + subElement = ET.Element('sub') + subElement.attrib['name'] = sub[0] + subElement.attrib['with'] = sub[1] + ruleElement.append(subElement) + self.root.findall('.rules')[0].append(ruleElement) + + def _addAxis(self, axisObject): + self.axes.append(axisObject) + axisElement = ET.Element('axis') + axisElement.attrib['tag'] = axisObject.tag + axisElement.attrib['name'] = axisObject.name + axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum) + axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum) + axisElement.attrib['default'] = self.intOrFloat(axisObject.default) + if axisObject.hidden: + axisElement.attrib['hidden'] = "1" + for languageCode, labelName in axisObject.labelNames.items(): + languageElement = ET.Element('labelname') + languageElement.attrib[u'xml:lang'] = languageCode + languageElement.text = labelName + axisElement.append(languageElement) + if axisObject.map: + for inputValue, outputValue in axisObject.map: + mapElement = ET.Element('map') + mapElement.attrib['input'] = self.intOrFloat(inputValue) + mapElement.attrib['output'] = self.intOrFloat(outputValue) + axisElement.append(mapElement) + self.root.findall('.axes')[0].append(axisElement) + + def _addInstance(self, instanceObject): + instanceElement = ET.Element('instance') + if instanceObject.name is not None: + instanceElement.attrib['name'] = instanceObject.name + if instanceObject.familyName is not None: + instanceElement.attrib['familyname'] = instanceObject.familyName + if instanceObject.styleName is not None: + instanceElement.attrib['stylename'] = instanceObject.styleName + # add localisations + if instanceObject.localisedStyleName: + languageCodes = instanceObject.localisedStyleName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue # already stored in the element attribute + localisedStyleNameElement = ET.Element('stylename') + localisedStyleNameElement.attrib["xml:lang"] = code + localisedStyleNameElement.text = instanceObject.getStyleName(code) + instanceElement.append(localisedStyleNameElement) + if instanceObject.localisedFamilyName: + languageCodes = instanceObject.localisedFamilyName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue # already stored in the element attribute + localisedFamilyNameElement = ET.Element('familyname') + localisedFamilyNameElement.attrib["xml:lang"] = code + localisedFamilyNameElement.text = instanceObject.getFamilyName(code) + instanceElement.append(localisedFamilyNameElement) + if instanceObject.localisedStyleMapStyleName: + languageCodes = instanceObject.localisedStyleMapStyleName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue + localisedStyleMapStyleNameElement = ET.Element('stylemapstylename') + localisedStyleMapStyleNameElement.attrib["xml:lang"] = code + localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code) + instanceElement.append(localisedStyleMapStyleNameElement) + if instanceObject.localisedStyleMapFamilyName: + languageCodes = instanceObject.localisedStyleMapFamilyName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue + localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname') + localisedStyleMapFamilyNameElement.attrib["xml:lang"] = code + localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code) + instanceElement.append(localisedStyleMapFamilyNameElement) + + if instanceObject.location is not None: + locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) + instanceElement.append(locationElement) + if instanceObject.filename is not None: + instanceElement.attrib['filename'] = instanceObject.filename + if instanceObject.postScriptFontName is not None: + instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName + if instanceObject.styleMapFamilyName is not None: + instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName + if instanceObject.styleMapStyleName is not None: + instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName + if instanceObject.glyphs: + if instanceElement.findall('.glyphs') == []: + glyphsElement = ET.Element('glyphs') + instanceElement.append(glyphsElement) + glyphsElement = instanceElement.findall('.glyphs')[0] + for glyphName, data in instanceObject.glyphs.items(): + glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) + glyphsElement.append(glyphElement) + if instanceObject.kerning: + kerningElement = ET.Element('kerning') + instanceElement.append(kerningElement) + if instanceObject.info: + infoElement = ET.Element('info') + instanceElement.append(infoElement) + if instanceObject.lib: + libElement = ET.Element('lib') + # TODO: (jany) PLIST I guess? + libElement.text = json.dumps(instanceObject.lib) + instanceElement.append(libElement) + self.root.findall('.instances')[0].append(instanceElement) + + def _addSource(self, sourceObject): + sourceElement = ET.Element("source") + if sourceObject.filename is not None: + sourceElement.attrib['filename'] = sourceObject.filename + if sourceObject.name is not None: + if sourceObject.name.find("temp_master")!=0: + # do not save temporary source names + sourceElement.attrib['name'] = sourceObject.name + if sourceObject.familyName is not None: + sourceElement.attrib['familyname'] = sourceObject.familyName + if sourceObject.styleName is not None: + sourceElement.attrib['stylename'] = sourceObject.styleName + if sourceObject.copyLib: + libElement = ET.Element('lib') + libElement.attrib['copy'] = "1" + sourceElement.append(libElement) + if sourceObject.copyGroups: + groupsElement = ET.Element('groups') + groupsElement.attrib['copy'] = "1" + sourceElement.append(groupsElement) + if sourceObject.copyFeatures: + featuresElement = ET.Element('features') + featuresElement.attrib['copy'] = "1" + sourceElement.append(featuresElement) + if sourceObject.copyInfo or sourceObject.muteInfo: + infoElement = ET.Element('info') + if sourceObject.copyInfo: + infoElement.attrib['copy'] = "1" + if sourceObject.muteInfo: + infoElement.attrib['mute'] = "1" + sourceElement.append(infoElement) + if sourceObject.muteKerning: + kerningElement = ET.Element("kerning") + kerningElement.attrib["mute"] = '1' + sourceElement.append(kerningElement) + if sourceObject.mutedGlyphNames: + for name in sourceObject.mutedGlyphNames: + glyphElement = ET.Element("glyph") + glyphElement.attrib["name"] = name + glyphElement.attrib["mute"] = '1' + sourceElement.append(glyphElement) + locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location) + sourceElement.append(locationElement) + self.root.findall('.sources')[0].append(sourceElement) + + def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): + glyphElement = ET.Element('glyph') + if data.get('mute'): + glyphElement.attrib['mute'] = "1" + if data.get('unicodes') is not None: + glyphElement.attrib['unicode'] = " ".join([hex(u) for u in data.get('unicodes')]) + if data.get('instanceLocation') is not None: + locationElement, data['instanceLocation'] = self._makeLocationElement(data.get('instanceLocation')) + glyphElement.append(locationElement) + if glyphName is not None: + glyphElement.attrib['name'] = glyphName + if data.get('note') is not None: + noteElement = ET.Element('note') + noteElement.text = data.get('note') + glyphElement.append(noteElement) + if data.get('masters') is not None: + mastersElement = ET.Element("masters") + for m in data.get('masters'): + masterElement = ET.Element("master") + if m.get('glyphName') is not None: + masterElement.attrib['glyphname'] = m.get('glyphName') + if m.get('font') is not None: + masterElement.attrib['source'] = m.get('font') + if m.get('location') is not None: + locationElement, m['location'] = self._makeLocationElement(m.get('location')) + masterElement.append(locationElement) + mastersElement.append(masterElement) + glyphElement.append(mastersElement) + return glyphElement + + +class BaseDocReader(object): + def __init__(self, documentPath, documentObject): + self.path = documentPath + self.documentObject = documentObject + self.documentObject.formatVersion = 0 + tree = ET.parse(self.path) + self.root = tree.getroot() + self.documentObject.formatVersion = int(self.root.attrib.get("format", 0)) + self.axes = [] + self.rules = [] + self.sources = [] + self.instances = [] + self.axisDefaults = {} + self._strictAxisNames = True + + def read(self): + self.readAxes() + self.readRules() + self.readSources() + self.readInstances() + + def getSourcePaths(self, makeGlyphs=True, makeKerning=True, makeInfo=True): + paths = [] + for name in self.documentObject.sources.keys(): + paths.append(self.documentObject.sources[name][0].path) + return paths + + def newDefaultLocation(self): + loc = {} + for axisDescriptor in self.axes: + loc[axisDescriptor.name] = axisDescriptor.default + return loc + + def readRules(self): + # read the rules + rules = [] + for ruleElement in self.root.findall(".rules/rule"): + ruleObject = self.documentObject.newRuleDescriptor() + ruleObject.name = ruleElement.attrib.get("name") + for conditionElement in ruleElement.findall('.condition'): + cd = {} + cdMin = conditionElement.attrib.get("minimum") + if cdMin is not None: + cd['minimum'] = float(cdMin) + else: + # will allow these to be None, assume axis.minimum + cd['minimum'] = None + cdMax = conditionElement.attrib.get("maximum") + if cdMax is not None: + cd['maximum'] = float(cdMax) + else: + # will allow these to be None, assume axis.maximum + cd['maximum'] = None + cd['name'] = conditionElement.attrib.get("name") + ruleObject.conditions.append(cd) + for subElement in ruleElement.findall('.sub'): + a = subElement.attrib['name'] + b = subElement.attrib['with'] + ruleObject.subs.append((a,b)) + rules.append(ruleObject) + self.documentObject.rules = rules + + def readAxes(self): + # read the axes elements, including the warp map. + axes = [] + if len(self.root.findall(".axes/axis"))==0: + self.guessAxes() + self._strictAxisNames = False + return + for axisElement in self.root.findall(".axes/axis"): + axisObject = self.documentObject.newAxisDescriptor() + axisObject.name = axisElement.attrib.get("name") + axisObject.minimum = float(axisElement.attrib.get("minimum")) + axisObject.maximum = float(axisElement.attrib.get("maximum")) + if axisElement.attrib.get('hidden', False): + axisObject.hidden = True + # we need to check if there is an attribute named "initial" + if axisElement.attrib.get("default") is None: + if axisElement.attrib.get("initial") is not None: + # stop doing this, + axisObject.default = float(axisElement.attrib.get("initial")) + else: + axisObject.default = axisObject.minimum + else: + axisObject.default = float(axisElement.attrib.get("default")) + axisObject.tag = axisElement.attrib.get("tag") + for mapElement in axisElement.findall('map'): + a = float(mapElement.attrib['input']) + b = float(mapElement.attrib['output']) + axisObject.map.append((a,b)) + for labelNameElement in axisElement.findall('labelname'): + # Note: elementtree reads the xml:lang attribute name as + # '{http://www.w3.org/XML/1998/namespace}lang' + for key, lang in labelNameElement.items(): + labelName = labelNameElement.text + axisObject.labelNames[lang] = labelName + self.documentObject.axes.append(axisObject) + self.axisDefaults[axisObject.name] = axisObject.default + + def _locationFromElement(self, locationElement): + # mostly duplicated from readLocationElement, Needs Resolve. + loc = {} + for dimensionElement in locationElement.findall(".dimension"): + dimName = dimensionElement.attrib.get("name") + xValue = yValue = None + try: + xValue = dimensionElement.attrib.get('xvalue') + xValue = float(xValue) + except ValueError: + self.logger.info("KeyError in readLocation xValue %3.3f", xValue) + try: + yValue = dimensionElement.attrib.get('yvalue') + if yValue is not None: + yValue = float(yValue) + except ValueError: + pass + if yValue is not None: + loc[dimName] = (xValue, yValue) + else: + loc[dimName] = xValue + return loc + + def guessAxes(self): + # Called when we have no axes element in the file. + # Look at all locations and collect the axis names and values + # assumptions: + # look for the default value on an axis from a master location + allLocations = [] + minima = {} + maxima = {} + for locationElement in self.root.findall(".sources/source/location"): + allLocations.append(self._locationFromElement(locationElement)) + for locationElement in self.root.findall(".instances/instance/location"): + allLocations.append(self._locationFromElement(locationElement)) + for loc in allLocations: + for dimName, value in loc.items(): + if not isinstance(value, tuple): + value = [value] + for v in value: + if dimName not in minima: + minima[dimName] = v + continue + if minima[dimName] > v: + minima[dimName] = v + if dimName not in maxima: + maxima[dimName] = v + continue + if maxima[dimName] < v: + maxima[dimName] = v + newAxes = [] + for axisName in maxima.keys(): + a = self.documentObject.newAxisDescriptor() + a.default = a.minimum = minima[axisName] + a.maximum = maxima[axisName] + a.name = axisName + a.tag, a.labelNames = tagForAxisName(axisName) + self.documentObject.axes.append(a) + + def readSources(self): + for sourceCount, sourceElement in enumerate(self.root.findall(".sources/source")): + filename = sourceElement.attrib.get('filename') + if filename is not None and self.path is not None: + sourcePath = os.path.abspath(os.path.join(os.path.dirname(self.path), filename)) + else: + sourcePath = None + sourceName = sourceElement.attrib.get('name') + if sourceName is None: + # add a temporary source name + sourceName = "temp_master.%d"%(sourceCount) + sourceObject = self.documentObject.newSourceDescriptor() + sourceObject.path = sourcePath # absolute path to the ufo source + sourceObject.filename = filename # path as it is stored in the document + sourceObject.name = sourceName + familyName = sourceElement.attrib.get("familyname") + if familyName is not None: + sourceObject.familyName = familyName + styleName = sourceElement.attrib.get("stylename") + if styleName is not None: + sourceObject.styleName = styleName + sourceObject.location = self.locationFromElement(sourceElement) + for libElement in sourceElement.findall('.lib'): + if libElement.attrib.get('copy') == '1': + sourceObject.copyLib = True + for groupsElement in sourceElement.findall('.groups'): + if groupsElement.attrib.get('copy') == '1': + sourceObject.copyGroups = True + for infoElement in sourceElement.findall(".info"): + if infoElement.attrib.get('copy') == '1': + sourceObject.copyInfo = True + if infoElement.attrib.get('mute') == '1': + sourceObject.muteInfo = True + for featuresElement in sourceElement.findall(".features"): + if featuresElement.attrib.get('copy') == '1': + sourceObject.copyFeatures = True + for glyphElement in sourceElement.findall(".glyph"): + glyphName = glyphElement.attrib.get('name') + if glyphName is None: + continue + if glyphElement.attrib.get('mute') == '1': + sourceObject.mutedGlyphNames.append(glyphName) + for kerningElement in sourceElement.findall(".kerning"): + if kerningElement.attrib.get('mute') == '1': + sourceObject.muteKerning = True + self.documentObject.sources.append(sourceObject) + + def locationFromElement(self, element): + elementLocation = None + for locationElement in element.findall('.location'): + elementLocation = self.readLocationElement(locationElement) + break + return elementLocation + + def readLocationElement(self, locationElement): + """ Format 0 location reader """ + loc = {} + for dimensionElement in locationElement.findall(".dimension"): + dimName = dimensionElement.attrib.get("name") + if self._strictAxisNames and dimName not in self.axisDefaults: + # In case the document contains axis definitions, + # then we should only read the axes we know about. + # However, if the document does not contain axes, + # then we need to create them after reading. + continue + xValue = yValue = None + try: + xValue = dimensionElement.attrib.get('xvalue') + xValue = float(xValue) + except ValueError: + self.logger.info("KeyError in readLocation xValue %3.3f", xValue) + try: + yValue = dimensionElement.attrib.get('yvalue') + if yValue is not None: + yValue = float(yValue) + except ValueError: + pass + if yValue is not None: + loc[dimName] = (xValue, yValue) + else: + loc[dimName] = xValue + return loc + + def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): + instanceElements = self.root.findall('.instances/instance') + for instanceElement in self.root.findall('.instances/instance'): + self._readSingleInstanceElement(instanceElement, makeGlyphs=makeGlyphs, makeKerning=makeKerning, makeInfo=makeInfo) + + def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True): + filename = instanceElement.attrib.get('filename') + if filename is not None: + instancePath = os.path.join(os.path.dirname(self.documentObject.path), filename) + filenameTokenForResults = os.path.basename(filename) + else: + instancePath = None + instanceObject = self.documentObject.newInstanceDescriptor() + instanceObject.path = instancePath # absolute path to the instance + instanceObject.filename = filename # path as it is stored in the document + name = instanceElement.attrib.get("name") + if name is not None: + instanceObject.name = name + familyname = instanceElement.attrib.get('familyname') + if familyname is not None: + instanceObject.familyName = familyname + stylename = instanceElement.attrib.get('stylename') + if stylename is not None: + instanceObject.styleName = stylename + postScriptFontName = instanceElement.attrib.get('postscriptfontname') + if postScriptFontName is not None: + instanceObject.postScriptFontName = postScriptFontName + styleMapFamilyName = instanceElement.attrib.get('stylemapfamilyname') + if styleMapFamilyName is not None: + instanceObject.styleMapFamilyName = styleMapFamilyName + styleMapStyleName = instanceElement.attrib.get('stylemapstylename') + if styleMapStyleName is not None: + instanceObject.styleMapStyleName = styleMapStyleName + # read localised names + for styleNameElement in instanceElement.findall('stylename'): + for key, lang in styleNameElement.items(): + styleName = styleNameElement.text + instanceObject.setStyleName(styleName, lang) + for familyNameElement in instanceElement.findall('familyname'): + for key, lang in familyNameElement.items(): + familyName = familyNameElement.text + instanceObject.setFamilyName(familyName, lang) + for styleMapStyleNameElement in instanceElement.findall('stylemapstylename'): + for key, lang in styleMapStyleNameElement.items(): + styleMapStyleName = styleMapStyleNameElement.text + instanceObject.setStyleMapStyleName(styleMapStyleName, lang) + for styleMapFamilyNameElement in instanceElement.findall('stylemapfamilyname'): + for key, lang in styleMapFamilyNameElement.items(): + styleMapFamilyName = styleMapFamilyNameElement.text + instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang) + instanceLocation = self.locationFromElement(instanceElement) + if instanceLocation is not None: + instanceObject.location = instanceLocation + for glyphElement in instanceElement.findall('.glyphs/glyph'): + self.readGlyphElement(glyphElement, instanceObject) + for infoElement in instanceElement.findall("info"): + self.readInfoElement(infoElement, instanceObject) + for libElement in instanceElement.findall('lib'): + self.readLibElement(libElement, instanceObject) + self.documentObject.instances.append(instanceObject) + + def readLibElement(self, libElement, instanceObject): + """ TODO: (jany) doc + """ + instanceObject.lib = json.loads(libElement.text) + + def readInfoElement(self, infoElement, instanceObject): + """ Read the info element. + + :: + + + + Let's drop support for a different location for the info. Never needed it. + + """ + infoLocation = self.locationFromElement(infoElement) + instanceObject.info = True + + def readKerningElement(self, kerningElement, instanceObject): + """ Read the kerning element. + + :: + + Make kerning at the location and with the masters specified at the instance level. + + + """ + kerningLocation = self.locationFromElement(kerningElement) + instanceObject.addKerning(kerningLocation) + + def readGlyphElement(self, glyphElement, instanceObject): + """ + Read the glyph element. + + :: + + + + + + + + + + + This is an instance from an anisotropic interpolation. + + + + """ + glyphData = {} + glyphName = glyphElement.attrib.get('name') + if glyphName is None: + raise DesignSpaceDocumentError("Glyph object without name attribute.") + mute = glyphElement.attrib.get("mute") + if mute == "1": + glyphData['mute'] = True + # unicode + unicodes = glyphElement.attrib.get('unicode') + if unicodes is not None: + try: + unicodes = [int(u, 16) for u in unicodes.split(" ")] + glyphData['unicodes'] = unicodes + except ValueError: + raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes) + + note = None + for noteElement in glyphElement.findall('.note'): + glyphData['note'] = noteElement.text + break + instanceLocation = self.locationFromElement(glyphElement) + if instanceLocation is not None: + glyphData['instanceLocation'] = instanceLocation + glyphSources = None + for masterElement in glyphElement.findall('.masters/master'): + fontSourceName = masterElement.attrib.get('source') + sourceLocation = self.locationFromElement(masterElement) + masterGlyphName = masterElement.attrib.get('glyphname') + if masterGlyphName is None: + # if we don't read a glyphname, use the one we have + masterGlyphName = glyphName + d = dict(font=fontSourceName, + location=sourceLocation, + glyphName=masterGlyphName) + if glyphSources is None: + glyphSources = [] + glyphSources.append(d) + if glyphSources is not None: + glyphData['masters'] = glyphSources + instanceObject.glyphs[glyphName] = glyphData + + +class DesignSpaceDocument(object): + """ Read, write data from the designspace file""" + def __init__(self, readerClass=None, writerClass=None, fontClass=None): + self.logger = logging.getLogger("DesignSpaceDocumentLog") + self.path = None + self.formatVersion = None + self.sources = [] + self.instances = [] + self.axes = [] + self.rules = [] + self.default = None # name of the default master + self.defaultLoc = None + # + if readerClass is not None: + self.readerClass = readerClass + else: + self.readerClass = BaseDocReader + if writerClass is not None: + self.writerClass = writerClass + else: + self.writerClass = BaseDocWriter + if fontClass is not None: + self.fontClass = fontClass + else: + from defcon.objects.font import Font + self.fontClass = Font + + def read(self, path): + self.path = path + reader = self.readerClass(path, self) + reader.read() + + def write(self, path): + self.path = path + self.updatePaths() + writer = self.writerClass(path, self) + writer.write() + + def _posixRelativePath(self, otherPath): + relative = os.path.relpath(otherPath, os.path.dirname(self.path)) + return posixpath.join(*relative.split(os.path.sep)) + + def updatePaths(self): + """ + Right before we save we need to identify and respond to the following situations: + In each descriptor, we have to do the right thing for the filename attribute. + + case 1. + descriptor.filename == None + descriptor.path == None + + -- action: + write as is, descriptors will not have a filename attr. + useless, but no reason to interfere. + + + case 2. + descriptor.filename == "../something" + descriptor.path == None + + -- action: + write as is. The filename attr should not be touched. + + + case 3. + descriptor.filename == None + descriptor.path == "~/absolute/path/there" + + -- action: + calculate the relative path for filename. + We're not overwriting some other value for filename, it should be fine + + + case 4. + descriptor.filename == '../somewhere' + descriptor.path == "~/absolute/path/there" + + -- action: + there is a conflict between the given filename, and the path. + So we know where the file is relative to the document. + Can't guess why they're different, we just choose for path to be correct and update filename. + + + """ + for descriptor in self.sources + self.instances: + # check what the relative path really should be? + expectedFilename = None + if descriptor.path is not None and self.path is not None: + expectedFilename = self._posixRelativePath(descriptor.path) + + # 3 + if descriptor.filename is None and descriptor.path is not None and self.path is not None: + descriptor.filename = self._posixRelativePath(descriptor.path) + continue + + # 4 + if descriptor.filename is not None and descriptor.path is not None and self.path is not None: + if descriptor.filename is not expectedFilename: + descriptor.filename = expectedFilename + + def addSource(self, sourceDescriptor): + self.sources.append(sourceDescriptor) + + def addInstance(self, instanceDescriptor): + self.instances.append(instanceDescriptor) + + def addAxis(self, axisDescriptor): + self.axes.append(axisDescriptor) + + def addRule(self, ruleDescriptor): + self.rules.append(ruleDescriptor) + + def newDefaultLocation(self): + loc = {} + for axisDescriptor in self.axes: + loc[axisDescriptor.name] = axisDescriptor.default + return loc + + def updateFilenameFromPath(self, masters=True, instances=True, force=False): + # set a descriptor filename attr from the path and this document path + # if the filename attribute is not None: skip it. + if masters: + for descriptor in self.sources: + if descriptor.filename is not None and not force: + continue + if self.path is not None: + descriptor.filename = self._posixRelativePath(descriptor.path) + if instances: + for descriptor in self.instances: + if descriptor.filename is not None and not force: + continue + if self.path is not None: + descriptor.filename = self._posixRelativePath(descriptor.path) + + def getFonts(self): + # convenience method that delivers the masters and their locations + # so someone can build a thing for a thing. + fonts = [] + for sourceDescriptor in self.sources: + if sourceDescriptor.path is not None: + if os.path.exists(sourceDescriptor.path): + f = self.fontClass(sourceDescriptor.path) + fonts.append((f, sourceDescriptor.location)) + return fonts + + def newAxisDescriptor(self): + # Ask the writer class to make us a new axisDescriptor + return self.writerClass.getAxisDecriptor(self) + + def newSourceDescriptor(self): + # Ask the writer class to make us a new sourceDescriptor + return self.writerClass.getSourceDescriptor(self) + + def newInstanceDescriptor(self): + # Ask the writer class to make us a new instanceDescriptor + return self.writerClass.getInstanceDescriptor(self) + + def newRuleDescriptor(self): + # Ask the writer class to make us a new instanceDescriptor + return self.writerClass.getRuleDescriptor(self) + + def getAxisOrder(self): + names = [] + for axisDescriptor in self.axes: + names.append(axisDescriptor.name) + return names + + def getAxis(self, name): + for axisDescriptor in self.axes: + if axisDescriptor.name == name: + return axisDescriptor + return None + + def check(self): + """ + After reading we need to make sure we have a valid designspace. + This means making repairs if things are missing + - check if we have axes and deduce them from the masters if they're missing + - that can include axes referenced in masters, instances, glyphs. + - if no default is assigned, use mutatormath to find out. + - record the default in the designspace + - report all the changes in a log + - save a "repaired" version of the doc + """ + self.checkAxes() + self.checkDefault() + + def checkDefault(self): + """ Check the sources for a copyInfo flag.""" + flaggedDefaultCandidate = None + for sourceDescriptor in self.sources: + names = set() + if sourceDescriptor.copyInfo: + # we choose you! + flaggedDefaultCandidate = sourceDescriptor + masterLocations = [src.location for src in self.sources] + mutatorBias = biasFromLocations(masterLocations, preferOrigin=False) + c = [src for src in self.sources if src.location==mutatorBias] + if c: + mutatorDefaultCandidate = c[0] + else: + mutatorDefaultCandidate = None + # what are we going to do? + if flaggedDefaultCandidate is not None: + if mutatorDefaultCandidate is not None: + if mutatorDefaultCandidate.name != flaggedDefaultCandidate.name: + # warn if we have a conflict + self.logger.info("Note: conflicting default masters:\n\tUsing %s as default\n\tMutator found %s"%(flaggedDefaultCandidate.name, mutatorDefaultCandidate.name)) + self.default = flaggedDefaultCandidate + self.defaultLoc = self.default.location + else: + # we have no flagged default candidate + # let's use the one from mutator + if flaggedDefaultCandidate is None and mutatorDefaultCandidate is not None: + # we didn't have a flag, use the one selected by mutator + self.default = mutatorDefaultCandidate + self.defaultLoc = self.default.location + self.default.copyInfo = True + # now that we have a default, let's check if the axes are ok + for axisObj in self.axes: + if axisObj.name not in self.default.location: + # extend the location of the neutral master with missing default value for this axis + self.default.location[axisObj.name] = axisObj.default + else: + if axisObj.default == self.default.location.get(axisObj.name): + continue + # proposed remedy: change default value in the axisdescriptor to the value of the neutral + neutralAxisValue = self.default.location.get(axisObj.name) + # make sure this value is between the min and max + if axisObj.minimum <= neutralAxisValue <= axisObj.maximum: + # yes we can fix this + axisObj.default = neutralAxisValue + self.logger.info("Note: updating the default value of axis %s to neutral master at %3.3f"%(axisObj.name, neutralAxisValue)) + # always fit the axis dimensions to the location of the designated neutral + elif neutralAxisValue < axisObj.minimum: + axisObj.default = neutralAxisValue + axisObj.minimum = neutralAxisValue + elif neutralAxisValue > axisObj.maximum: + axisObj.maximum = neutralAxisValue + axisObj.default = neutralAxisValue + else: + # now we're in trouble, can't solve this, alert. + self.logger.info("Warning: mismatched default value for axis %s and neutral master. Master value outside of axis bounds"%(axisObj.name)) + + + def _prepAxesForBender(self): + """ + Make the axis data we have available in + """ + benderAxes = {} + for axisDescriptor in self.axes: + d = { + 'name': axisDescriptor.name, + 'tag': axisDescriptor.tag, + 'minimum': axisDescriptor.minimum, + 'maximum': axisDescriptor.maximum, + 'default': axisDescriptor.default, + 'map': axisDescriptor.map, + } + benderAxes[axisDescriptor.name] = d + return benderAxes + + def checkAxes(self, overwrite=False): + """ + If we don't have axes in the document, make some, report + Should we include the instance locations when determining the axis extrema? + """ + axisValues = {} + # find all the axes + locations = [] + for sourceDescriptor in self.sources: + locations.append(sourceDescriptor.location) + for instanceDescriptor in self.instances: + locations.append(instanceDescriptor.location) + for name, glyphData in instanceDescriptor.glyphs.items(): + loc = glyphData.get("instanceLocation") + if loc is not None: + locations.append(loc) + for m in glyphData.get('masters', []): + locations.append(m['location']) + for loc in locations: + for name, value in loc.items(): + if not name in axisValues: + axisValues[name] = [] + if type(value)==tuple: + for v in value: + axisValues[name].append(v) + else: + axisValues[name].append(value) + have = self.getAxisOrder() + for name, values in axisValues.items(): + a = None + if name in have: + if overwrite: + # we have the axis, + a = self.getAxis(name) + else: + continue + else: + # we need to make this axis + a = self.newAxisDescriptor() + self.addAxis(a) + a.name = name + a.minimum = min(values) + a.maximum = max(values) + a.default = a.minimum + a.tag, a.labelNames = tagForAxisName(a.name) + self.logger.info("CheckAxes: added a missing axis %s, %3.3f %3.3f", a.name, a.minimum, a.maximum) + + + def normalizeLocation(self, location): + # scale this location based on the axes + # accept only values for the axes that we have definitions for + # only normalise if we're valid? + # normalise anisotropic cooordinates to isotropic. + # copied from fontTools.varlib.models.normalizeLocation + new = {} + for axis in self.axes: + if not axis.name in location: + # skipping this dimension it seems + continue + v = location.get(axis.name, axis.default) + if type(v)==tuple: + v = v[0] + if v == axis.default: + v = 0.0 + elif v < axis.default: + if axis.default == axis.minimum: + v = 0.0 + else: + v = (max(v, axis.minimum) - axis.default) / (axis.default - axis.minimum) + else: + if axis.default == axis.maximum: + v = 0.0 + else: + v = (min(v, axis.maximum) - axis.default) / (axis.maximum - axis.default) + new[axis.name] = v + return new + + def normalize(self): + # scale all the locations of all masters and instances to the -1 - 0 - 1 value. + # we need the axis data to do the scaling, so we do those last. + # masters + for item in self.sources: + item.location = self.normalizeLocation(item.location) + # instances + for item in self.instances: + # glyph masters for this instance + for name, glyphData in item.glyphs.items(): + glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation']) + for glyphMaster in glyphData['masters']: + glyphMaster['location'] = self.normalizeLocation(glyphMaster['location']) + item.location = self.normalizeLocation(item.location) + # now the axes + for axis in self.axes: + # scale the map first + newMap = [] + for inputValue, outputValue in axis.map: + newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name) + newMap.append((inputValue, newOutputValue)) + if newMap: + axis.map = newMap + # finally the axis values + minimum = self.normalizeLocation({axis.name:axis.minimum}).get(axis.name) + maximum = self.normalizeLocation({axis.name:axis.maximum}).get(axis.name) + default = self.normalizeLocation({axis.name:axis.default}).get(axis.name) + # and set them in the axis.minimum + axis.minimum = minimum + axis.maximum = maximum + axis.default = default + # now the rules + for rule in self.rules: + newConditions = [] + for cond in rule.conditions: + if cond.get('minimum') is not None: + minimum = self.normalizeLocation({cond['name']:cond['minimum']}).get(cond['name']) + else: + minimum = None + if cond.get('maximum') is not None: + maximum = self.normalizeLocation({cond['name']:cond['maximum']}).get(cond['name']) + else: + maximum = None + newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum)) + rule.conditions = newConditions + + +class InMemorySourceDescriptor(SourceDescriptor): + """A source descriptor that has a reference to an instance of a defcon Font + loaded in memory. + """ + + def __init__(self, fontClass=None): + super(InMemorySourceDescriptor, self).__init__() + self._font = None + if fontClass is not None: + self.fontClass = fontClass + else: + from defcon.objects.font import Font + self.fontClass = Font + + @property + def font(self): + if self._font is not None: + return self._font + + # FIXME: (jany) will there always be a path? + if self.path: + self._font = self.fontClass(self.path) + + return self._font + + @font.setter + def font(self, font): + self._font = font + # FIXME: (jany) not sure the following is correct + # Intent: if the given font already has a path, use it + # else write the font next to the path specified for the descriptor + # else write the font next to the designSpaceDocument. + # else use a default path relative to the descriptor. + if font.path: + self.path = font.path + self.filename = None + # No need to compute the correct relative filename + + # The font's path is not updated here. Instead, a correct value + # will be computed by the writer, depending on where the whole + # document is going to be written. + + +class InMemoryDocWriter(BaseDocWriter): + """Writes in-memory UFOs next to the designspace.""" + + sourceDescriptorClass = InMemorySourceDescriptor + + @classmethod + def getSourceDescriptor(cls, document): + return cls.sourceDescriptorClass(fontClass=document.fontClass) + + def write(self, pretty=True): + super(InMemoryDocWriter, self).write(pretty) + for sourceObject in self.documentObject.sources: + if not sourceObject.filename: + self.documentObject.logger.warn( + 'In-memory source font {font} not written to the disk ' + 'because its descriptor does not have a filename.'.format( + font=sourceObject.font)) + continue + path = os.path.join( + os.path.dirname(self.path), sourceObject.filename) + sourceObject.font.save(path) + + +def rulesToFeature(doc, whiteSpace="\t", newLine="\n"): + """ Showing how rules could be expressed as FDK feature text. + Speculative. Experimental. + """ + axisNames = {axis.name: axis.tag for axis in doc.axes} + axisDims = {axis.tag: (axis.minimum, axis.maximum) for axis in doc.axes} + text = [] + for rule in doc.rules: + text.append("rule %s{"%rule.name) + for cd in rule.conditions: + axisTag = axisNames.get(cd.get('name'), "****") + axisMinimum = cd.get('minimum', axisDims.get(axisTag, [0,0])[0]) + axisMaximum = cd.get('maximum', axisDims.get(axisTag, [0,0])[1]) + text.append("%s%s %f %f;"%(whiteSpace, axisTag, axisMinimum, axisMaximum)) + text.append("} %s;"%rule.name) + return newLine.join(text) + +if __name__ == "__main__": + + def __removeAxesFromDesignSpace(path): + # only for testing, so we can make an invalid designspace file + # without making the designSpaceDocument also support it. + f = open(path, 'r') + d = f.read() + f.close() + start = d.find("") + end = d.find("")+len("") + n = d[0:start] + d[end:] + f = open(path, 'w') + f.write(n) + f.close() + + def test(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "test.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyLib = True + >>> s1.copyInfo = True + >>> s1.copyFeatures = True + >>> s1.location = dict(weight=0) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> s1.mutedGlyphNames.append("A") + >>> s1.mutedGlyphNames.append("Z") + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo2" + >>> s2.copyLib = False + >>> s2.copyInfo = False + >>> s2.copyFeatures = False + >>> s2.muteKerning = True + >>> s2.location = dict(weight=1000) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.familyName = "InstanceFamilyName" + >>> i1.styleName = "InstanceStyleName" + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(weight=500, spooky=666) # this adds a dimension that is not defined. + >>> i1.postScriptFontName = "InstancePostscriptName" + >>> i1.styleMapFamilyName = "InstanceStyleMapFamilyName" + >>> i1.styleMapStyleName = "InstanceStyleMapStyleName" + >>> glyphData = dict(name="arrow", mute=True, unicodes=[0x123, 0x124, 0x125]) + >>> i1.glyphs['arrow'] = glyphData + >>> doc.addInstance(i1) + >>> # add instance 2 + >>> i2 = InstanceDescriptor() + >>> i2.filename = os.path.relpath(instancePath2, os.path.dirname(testDocPath)) + >>> i2.familyName = "InstanceFamilyName" + >>> i2.styleName = "InstanceStyleName" + >>> i2.name = "instance.ufo2" + >>> # anisotropic location + >>> i2.location = dict(weight=500, width=(400,300)) + >>> i2.postScriptFontName = "InstancePostscriptName" + >>> i2.styleMapFamilyName = "InstanceStyleMapFamilyName" + >>> i2.styleMapStyleName = "InstanceStyleMapStyleName" + >>> glyphMasters = [dict(font="master.ufo1", glyphName="BB", location=dict(width=20,weight=20)), dict(font="master.ufo2", glyphName="CC", location=dict(width=900,weight=900))] + >>> glyphData = dict(name="arrow", unicodes=[101, 201, 301]) + >>> glyphData['masters'] = glyphMasters + >>> glyphData['note'] = "A note about this glyph" + >>> glyphData['instanceLocation'] = dict(width=100, weight=120) + >>> i2.glyphs['arrow'] = glyphData + >>> i2.glyphs['arrow2'] = dict(mute=False) + >>> doc.addInstance(i2) + >>> # now we have sources and instances, but no axes yet. + >>> doc.check() + >>> doc.getAxisOrder() + ['spooky', 'weight', 'width'] + >>> doc.axes = [] # clear the axes + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> # note: just to test the element language, not an actual label name recommendations. + >>> a1.labelNames[u'fa-IR'] = u"قطر" + >>> a1.labelNames[u'en'] = u"Wéíght" + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.minimum = 0 + >>> a2.maximum = 1000 + >>> a2.default = 20 + >>> a2.name = "width" + >>> a2.tag = "wdth" + >>> a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> a2.hidden = True + >>> a2.labelNames[u'fr'] = u"Poids" + >>> doc.addAxis(a2) + >>> # add an axis that is not part of any location to see if that works + >>> a3 = AxisDescriptor() + >>> a3.minimum = 333 + >>> a3.maximum = 666 + >>> a3.default = 444 + >>> a3.name = "spooky" + >>> a3.tag = "spok" + >>> a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> #doc.addAxis(a3) # uncomment this line to test the effects of default axes values + >>> # write some rules + >>> r1 = RuleDescriptor() + >>> r1.name = "named.rule.1" + >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1)) + >>> r1.conditions.append(dict(name='bbbb', minimum=2, maximum=3)) + >>> r1.subs.append(("a", "a.alt")) + >>> doc.addRule(r1) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.check() + >>> new.default.location + {'width': 20.0, 'weight': 0.0} + + # >>> for a, b in zip(doc.instances, new.instances): + # ... a.compare(b) + # >>> for a, b in zip(doc.sources, new.sources): + # ... a.compare(b) + # >>> for a, b in zip(doc.axes, new.axes): + # ... a.compare(b) + # >>> [n.mutedGlyphNames for n in new.sources] + # [['A', 'Z'], []] + # >>> doc.getFonts() + # [] + + >>> # test roundtrip for the axis attributes and data + >>> axes = {} + >>> for axis in doc.axes: + ... if not axis.tag in axes: + ... axes[axis.tag] = [] + ... axes[axis.tag].append(axis.serialize()) + >>> for axis in new.axes: + ... if axis.tag[0] == "_": continue + ... if not axis.tag in axes: + ... axes[axis.tag] = [] + ... axes[axis.tag].append(axis.serialize()) + >>> for v in axes.values(): + ... a, b = v + ... assert a == b + + """ + + def testAdjustAxisDefaultToNeutral(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testAdjustAxisDefaultToNeutral.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyInfo = True + >>> s1.copyFeatures = True + >>> s1.location = dict(weight=55, width=1000) + >>> doc.addSource(s1) + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 # the wrong value + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.minimum = -10 + >>> a2.maximum = 10 + >>> a2.default = 0 # the wrong value + >>> a2.name = "width" + >>> a2.tag = "wdth" + >>> doc.addAxis(a2) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.check() + >>> loc = new.default.location + >>> for axisObj in new.axes: + ... n = axisObj.name + ... assert axisObj.default == loc.get(n) + """ + + def testUnicodes(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testUnicodes.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testUnicodes_roundtrip.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyInfo = True + >>> s1.location = dict(weight=0) + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo2" + >>> s2.location = dict(weight=1000) + >>> doc.addSource(s2) + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(weight=500) + >>> glyphData = dict(name="arrow", mute=True, unicodes=[100, 200, 300]) + >>> i1.glyphs['arrow'] = glyphData + >>> doc.addInstance(i1) + >>> # now we have sources and instances, but no axes yet. + >>> doc.axes = [] # clear the axes + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> doc.addAxis(a1) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.write(testDocPath2) + >>> # compare the file contents + >>> f1 = open(testDocPath, 'r') + >>> t1 = f1.read() + >>> f1.close() + >>> f2 = open(testDocPath2, 'r') + >>> t2 = f2.read() + >>> f2.close() + >>> t1 == t2 + True + >>> # check the unicode values read from the document + >>> new.instances[0].glyphs['arrow']['unicodes'] == [100,200,300] + True + """ + + def testLocalisedNames(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testLocalisedNames.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testLocalisedNames_roundtrip.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyInfo = True + >>> s1.location = dict(weight=0) + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo2" + >>> s2.location = dict(weight=1000) + >>> doc.addSource(s2) + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.familyName = "Montserrat" + >>> i1.styleName = "SemiBold" + >>> i1.styleMapFamilyName = "Montserrat SemiBold" + >>> i1.styleMapStyleName = "Regular" + >>> i1.setFamilyName("Montserrat", "fr") + >>> i1.setFamilyName(u"モンセラート", "ja") + >>> i1.setStyleName("Demigras", "fr") + >>> i1.setStyleName(u"半ば", "ja") + >>> i1.setStyleMapStyleName(u"Standard", "de") + >>> i1.setStyleMapFamilyName("Montserrat Halbfett", "de") + >>> i1.setStyleMapFamilyName(u"モンセラート SemiBold", "ja") + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(weight=500, spooky=666) # this adds a dimension that is not defined. + >>> i1.postScriptFontName = "InstancePostscriptName" + >>> glyphData = dict(name="arrow", mute=True, unicodes=[0x123]) + >>> i1.glyphs['arrow'] = glyphData + >>> doc.addInstance(i1) + >>> # now we have sources and instances, but no axes yet. + >>> doc.axes = [] # clear the axes + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> # note: just to test the element language, not an actual label name recommendations. + >>> a1.labelNames[u'fa-IR'] = u"قطر" + >>> a1.labelNames[u'en'] = u"Wéíght" + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.minimum = 0 + >>> a2.maximum = 1000 + >>> a2.default = 0 + >>> a2.name = "width" + >>> a2.tag = "wdth" + >>> a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> a2.labelNames[u'fr'] = u"Poids" + >>> doc.addAxis(a2) + >>> # add an axis that is not part of any location to see if that works + >>> a3 = AxisDescriptor() + >>> a3.minimum = 333 + >>> a3.maximum = 666 + >>> a3.default = 444 + >>> a3.name = "spooky" + >>> a3.tag = "spok" + >>> a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> #doc.addAxis(a3) # uncomment this line to test the effects of default axes values + >>> # write some rules + >>> r1 = RuleDescriptor() + >>> r1.name = "named.rule.1" + >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1)) + >>> r1.conditions.append(dict(name='bbbb', minimum=2, maximum=3)) + >>> r1.subs.append(("a", "a.alt")) + >>> doc.addRule(r1) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.write(testDocPath2) + >>> f1 = open(testDocPath, 'r') + >>> t1 = f1.read() + >>> f1.close() + >>> f2 = open(testDocPath2, 'r') + >>> t2 = f2.read() + >>> f2.close() + >>> assert t1 == t2 + + """ + + def testHandleNoAxes(): + # test what happens if the designspacedocument has no axes element. + """ + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testNoAxes_source.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testNoAxes_recontructed.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + + # Case 1: No axes element in the document, but there are sources and instances + >>> doc = DesignSpaceDocument() + + >>> for name, value in [('One', 1),('Two', 2),('Three', 3)]: + ... a = AxisDescriptor() + ... a.minimum = 0 + ... a.maximum = 1000 + ... a.default = 0 + ... a.name = "axisName%s"%(name) + ... a.tag = "ax_%d"%(value) + ... doc.addAxis(a) + + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyLib = True + >>> s1.copyInfo = True + >>> s1.copyFeatures = True + >>> s1.location = dict(axisNameOne=-1000, axisNameTwo=0, axisNameThree=1000) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo1" + >>> s2.copyLib = False + >>> s2.copyInfo = False + >>> s2.copyFeatures = False + >>> s2.location = dict(axisNameOne=1000, axisNameTwo=1000, axisNameThree=0) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.familyName = "InstanceFamilyName" + >>> i1.styleName = "InstanceStyleName" + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(axisNameOne=(-1000,500), axisNameTwo=100) + >>> i1.postScriptFontName = "InstancePostscriptName" + >>> i1.styleMapFamilyName = "InstanceStyleMapFamilyName" + >>> i1.styleMapStyleName = "InstanceStyleMapStyleName" + >>> doc.addInstance(i1) + + >>> doc.write(testDocPath) + >>> __removeAxesFromDesignSpace(testDocPath) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath) + >>> verify.write(testDocPath2) + """ + + def testPathNameResolve(): + # test how descriptor.path and descriptor.filename are resolved + """ + >>> import os + >>> testDocPath1 = os.path.join(os.getcwd(), "testPathName_case1.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testPathName_case2.designspace") + >>> testDocPath3 = os.path.join(os.getcwd(), "testPathName_case3.designspace") + >>> testDocPath4 = os.path.join(os.getcwd(), "testPathName_case4.designspace") + >>> testDocPath5 = os.path.join(os.getcwd(), "testPathName_case5.designspace") + >>> testDocPath6 = os.path.join(os.getcwd(), "testPathName_case6.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + + # Case 1: filename and path are both empty. Nothing to calculate, nothing to put in the file. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = None + >>> s.path = None + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath1) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath1) + >>> assert verify.sources[0].filename == None + >>> assert verify.sources[0].path == None + + # Case 2: filename is empty, path points somewhere: calculate a new filename. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = None + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath2) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath2) + >>> assert verify.sources[0].filename == "masters/masterTest1.ufo" + >>> assert verify.sources[0].path == masterPath1 + + # Case 3: the filename is set, the path is None. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = "../somewhere/over/the/rainbow.ufo" + >>> s.path = None + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath3) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath3) + >>> assert verify.sources[0].filename == "../somewhere/over/the/rainbow.ufo" + >>> # make the absolute path for filename so we can see if it matches the path + >>> p = os.path.abspath(os.path.join(os.path.dirname(testDocPath3), verify.sources[0].filename)) + >>> assert verify.sources[0].path == p + + # Case 4: the filename points to one file, the path points to another. The path takes precedence. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = "../somewhere/over/the/rainbow.ufo" + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath4) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath4) + >>> assert verify.sources[0].filename == "masters/masterTest1.ufo" + + # Case 5: the filename is None, path has a value, update the filename + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = None + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath5) # so that the document has a path + >>> doc.updateFilenameFromPath() + >>> assert doc.sources[0].filename == "masters/masterTest1.ufo" + + # Case 6: the filename has a value, path has a value, update the filenames with force + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = "../somewhere/over/the/rainbow.ufo" + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.write(testDocPath5) # so that the document has a path + >>> doc.addSource(s) + >>> assert doc.sources[0].filename == "../somewhere/over/the/rainbow.ufo" + >>> doc.updateFilenameFromPath(force=True) + >>> assert doc.sources[0].filename == "masters/masterTest1.ufo" + + """ + + def testNormalise(): + """ + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = -1000 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "aaa" + >>> a1.tag = "aaaa" + >>> doc.addAxis(a1) + + >>> doc.normalizeLocation(dict(aaa=0)) + {'aaa': 0.0} + >>> doc.normalizeLocation(dict(aaa=1000)) + {'aaa': 1.0} + >>> # clipping beyond max values: + >>> doc.normalizeLocation(dict(aaa=1001)) + {'aaa': 1.0} + >>> doc.normalizeLocation(dict(aaa=500)) + {'aaa': 0.5} + >>> doc.normalizeLocation(dict(aaa=-1000)) + {'aaa': -1.0} + >>> doc.normalizeLocation(dict(aaa=-1001)) + {'aaa': -1.0} + >>> # anisotropic coordinates normalise to isotropic + >>> doc.normalizeLocation(dict(aaa=(1000,-1000))) + {'aaa': 1.0} + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('aaa', -1.0, 0.0, 1.0)] + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a2 = AxisDescriptor() + >>> a2.minimum = 100 + >>> a2.maximum = 1000 + >>> a2.default = 100 + >>> a2.name = "bbb" + >>> doc.addAxis(a2) + >>> doc.normalizeLocation(dict(bbb=0)) + {'bbb': 0.0} + >>> doc.normalizeLocation(dict(bbb=1000)) + {'bbb': 1.0} + >>> # clipping beyond max values: + >>> doc.normalizeLocation(dict(bbb=1001)) + {'bbb': 1.0} + >>> doc.normalizeLocation(dict(bbb=500)) + {'bbb': 0.4444444444444444} + >>> doc.normalizeLocation(dict(bbb=-1000)) + {'bbb': 0.0} + >>> doc.normalizeLocation(dict(bbb=-1001)) + {'bbb': 0.0} + >>> # anisotropic coordinates normalise to isotropic + >>> doc.normalizeLocation(dict(bbb=(1000,-1000))) + {'bbb': 1.0} + >>> doc.normalizeLocation(dict(bbb=1001)) + {'bbb': 1.0} + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('bbb', 0.0, 0.0, 1.0)] + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a3 = AxisDescriptor() + >>> a3.minimum = -1000 + >>> a3.maximum = 0 + >>> a3.default = 0 + >>> a3.name = "ccc" + >>> doc.addAxis(a3) + >>> doc.normalizeLocation(dict(ccc=0)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=1)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=-1000)) + {'ccc': -1.0} + >>> doc.normalizeLocation(dict(ccc=-1001)) + {'ccc': -1.0} + + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('ccc', -1.0, 0.0, 0.0)] + + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a3 = AxisDescriptor() + >>> a3.minimum = 2000 + >>> a3.maximum = 3000 + >>> a3.default = 2000 + >>> a3.name = "ccc" + >>> doc.addAxis(a3) + >>> doc.normalizeLocation(dict(ccc=0)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=1)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=-1000)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=-1001)) + {'ccc': 0.0} + + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('ccc', 0.0, 0.0, 1.0)] + + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a4 = AxisDescriptor() + >>> a4.minimum = 0 + >>> a4.maximum = 1000 + >>> a4.default = 0 + >>> a4.name = "ddd" + >>> a4.map = [(0,100), (300, 500), (600, 500), (1000,900)] + >>> doc.addAxis(a4) + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.map)) + >>> r.sort() + >>> r + [('ddd', [(0, 0.1), (300, 0.5), (600, 0.5), (1000, 0.9)])] + + + """ + + def testCheck(): + """ + >>> # check if the checks are checking + >>> testDocPath = os.path.join(os.getcwd(), "testCheck.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + + >>> # no default selected + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.path = masterPath1 + >>> s1.name = "master.ufo1" + >>> s1.location = dict(snap=0, pop=10) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.path = masterPath2 + >>> s2.name = "master.ufo2" + >>> s2.location = dict(snap=1000, pop=20) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> doc.checkAxes() + >>> doc.getAxisOrder() + ['snap', 'pop'] + >>> assert doc.default == None + >>> doc.checkDefault() + >>> doc.default.name + 'master.ufo1' + + >>> # default selected + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.path = masterPath1 + >>> s1.name = "master.ufo1" + >>> s1.location = dict(snap=0, pop=10) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.path = masterPath2 + >>> s2.name = "master.ufo2" + >>> s2.copyInfo = True + >>> s2.location = dict(snap=1000, pop=20) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> doc.checkAxes() + >>> doc.getAxisOrder() + ['snap', 'pop'] + >>> assert doc.default == None + >>> doc.checkDefault() + >>> doc.default.name + 'master.ufo2' + + >>> # generate a doc without axes, save and read again + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.path = masterPath1 + >>> s1.name = "master.ufo1" + >>> s1.location = dict(snap=0, pop=10) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.path = masterPath2 + >>> s2.name = "master.ufo2" + >>> s2.location = dict(snap=1000, pop=20) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> doc.checkAxes() + >>> doc.write(testDocPath) + >>> __removeAxesFromDesignSpace(testDocPath) + + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> len(new.axes) + 2 + >>> new.checkAxes() + >>> len(new.axes) + 2 + >>> print([a.name for a in new.axes]) + ['snap', 'pop'] + >>> new.write(testDocPath) + + """ + + def testRules(): + """ + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testRules.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testRules_roundtrip.designspace") + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.tag = "taga" + >>> a1.name = "aaaa" + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.tag = "tagb" + >>> a2.name = "bbbb" + >>> a2.minimum = 0 + >>> a2.maximum = 3000 + >>> a2.default = 0 + >>> doc.addAxis(a2) + + >>> r1 = RuleDescriptor() + >>> r1.name = "named.rule.1" + >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1000)) + >>> r1.conditions.append(dict(name='bbbb', minimum=0, maximum=3000)) + >>> r1.subs.append(("a", "a.alt")) + >>> + >>> # rule with minium and maximum + >>> doc.addRule(r1) + >>> assert len(doc.rules) == 1 + >>> assert len(doc.rules[0].conditions) == 2 + >>> evaluateRule(r1, dict(aaaa = 500, bbbb = 0)) + True + >>> evaluateRule(r1, dict(aaaa = 0, bbbb = 0)) + True + >>> evaluateRule(r1, dict(aaaa = 1000, bbbb = 0)) + True + >>> evaluateRule(r1, dict(aaaa = 1000, bbbb = -100)) + False + >>> evaluateRule(r1, dict(aaaa = 1000.0001, bbbb = 0)) + False + >>> evaluateRule(r1, dict(aaaa = -0.0001, bbbb = 0)) + False + >>> evaluateRule(r1, dict(aaaa = -100, bbbb = 0)) + False + >>> processRules([r1], dict(aaaa = 500), ["a", "b", "c"]) + ['a.alt', 'b', 'c'] + >>> processRules([r1], dict(aaaa = 500), ["a.alt", "b", "c"]) + ['a.alt', 'b', 'c'] + >>> processRules([r1], dict(aaaa = 2000), ["a", "b", "c"]) + ['a', 'b', 'c'] + + >>> # rule with only a maximum + >>> r2 = RuleDescriptor() + >>> r2.name = "named.rule.2" + >>> r2.conditions.append(dict(name='aaaa', maximum=500)) + >>> r2.subs.append(("b", "b.alt")) + >>> + >>> evaluateRule(r2, dict(aaaa = 0)) + True + >>> evaluateRule(r2, dict(aaaa = -500)) + True + >>> evaluateRule(r2, dict(aaaa = 1000)) + False + + >>> # rule with only a minimum + >>> r3 = RuleDescriptor() + >>> r3.name = "named.rule.3" + >>> r3.conditions.append(dict(name='aaaa', minimum=500)) + >>> r3.subs.append(("c", "c.alt")) + >>> + >>> evaluateRule(r3, dict(aaaa = 0)) + False + >>> evaluateRule(r3, dict(aaaa = 1000)) + True + >>> evaluateRule(r3, dict(bbbb = 1000)) + True + + >>> # rule with only a minimum, maximum in separate conditions + >>> r4 = RuleDescriptor() + >>> r4.name = "named.rule.4" + >>> r4.conditions.append(dict(name='aaaa', minimum=500)) + >>> r4.conditions.append(dict(name='bbbb', maximum=500)) + >>> r4.subs.append(("c", "c.alt")) + >>> + >>> evaluateRule(r4, dict()) # is this what we expect though? + True + >>> evaluateRule(r4, dict(aaaa = 1000, bbbb = 0)) + True + >>> evaluateRule(r4, dict(aaaa = 0, bbbb = 0)) + False + >>> evaluateRule(r4, dict(aaaa = 1000, bbbb = 1000)) + False + + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "aaaa" + >>> a1.tag = "aaaa" + >>> b1 = AxisDescriptor() + >>> b1.minimum = 2000 + >>> b1.maximum = 3000 + >>> b1.default = 2000 + >>> b1.name = "bbbb" + >>> b1.tag = "bbbb" + >>> doc.addAxis(a1) + >>> doc.addAxis(b1) + >>> doc._prepAxesForBender() + {'aaaa': {'map': [], 'name': 'aaaa', 'default': 0, 'minimum': 0, 'maximum': 1000, 'tag': 'aaaa'}, 'bbbb': {'map': [], 'name': 'bbbb', 'default': 2000, 'minimum': 2000, 'maximum': 3000, 'tag': 'bbbb'}} + + + >>> doc.rules[0].conditions + [{'minimum': 0, 'maximum': 1000, 'name': 'aaaa'}, {'minimum': 0, 'maximum': 3000, 'name': 'bbbb'}] + + >>> doc.rules[0].subs + [('a', 'a.alt')] + + >>> doc.normalize() + >>> doc.rules[0].name + 'named.rule.1' + >>> doc.rules[0].conditions + [{'minimum': 0.0, 'maximum': 1.0, 'name': 'aaaa'}, {'minimum': 0.0, 'maximum': 1.0, 'name': 'bbbb'}] + + >>> doc.write(testDocPath) + >>> new = DesignSpaceDocument() + + >>> new.read(testDocPath) + >>> len(new.axes) + 4 + >>> len(new.rules) + 1 + >>> new.write(testDocPath2) + + """ + + p = "testCheck.designspace" + __removeAxesFromDesignSpace(p) + def _test(): + import doctest + doctest.testmod() + _test() 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/types.py b/Lib/glyphsLib/types.py index bb612f6fa..b11d5a205 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,78 @@ 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] + 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 +133,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 +142,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 +160,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 +169,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 +198,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 +228,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 +276,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 diff --git a/Lib/glyphsLib/util.py b/Lib/glyphsLib/util.py index 5502d437e..557a894c5 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 @@ -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/tests/builder_test.py b/tests/builder_test.py index 77bf502dd..b3ceec9ac 100644 --- a/tests/builder_test.py +++ b/tests/builder_test.py @@ -37,17 +37,19 @@ 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.builders import UFOBuilder, GlyphsBuilder +from glyphsLib.builder.paths import to_ufo_paths +from glyphsLib.builder.custom_params import (to_ufo_custom_params, + _set_default_params) 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) @@ -279,86 +281,115 @@ def test_linked_style_bold_italic(self): 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.master) 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) + 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): - set_custom_params(self.ufo, parsed=[('glyphOrder', ['A', 'B'])]) - self.assertEqual(self.ufo.lib[PUBLIC_PREFIX + 'glyphOrder'], ['A', 'B']) + 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) - set_custom_params(self.ufo, parsed=[('Has WWS Names', False)]) + self.master.customParameters['Has WWS Names'] = False + self.set_custom_params() self.assertEqual(self.ufo.info.openTypeOS2Selection, None) - set_custom_params(self.ufo, parsed=[('Use Typo Metrics', True)]) + self.master.customParameters['Use Typo Metrics'] = True + self.set_custom_params() 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.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): - set_custom_params(self.ufo, parsed=[('underlinePosition', -2)]) + self.master.customParameters['underlinePosition'] = -2 + self.set_custom_params() self.assertEqual(self.ufo.info.postscriptUnderlinePosition, -2) - set_custom_params(self.ufo, parsed=[('underlinePosition', 1)]) + # 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): - set_custom_params(self.ufo, parsed=[('underlineThickness', 100)]) + self.master.customParameters['underlineThickness'] = 100 + self.set_custom_params() self.assertEqual(self.ufo.info.postscriptUnderlineThickness, 100) - set_custom_params(self.ufo, parsed=[('underlineThickness', 0)]) + # 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): - 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]) + 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(filter1[1], is_pre=True)) + mock.call(pre_filter, is_pre=True)) self.assertEqual(mock_parse_glyphs_filter.call_args_list[1], - mock.call(filter2[1], is_pre=False)) + mock.call(filter1, is_pre=False)) self.assertEqual(mock_parse_glyphs_filter.call_args_list[2], - mock.call(filter3[1], is_pre=False)) + mock.call(filter2, is_pre=False)) def test_set_defaults(self): - set_default_params(self.ufo) + _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.font.customParameters['codePageRanges'] = [1252, 1250] + self.set_custom_params() self.assertEqual(self.ufo.info.openTypeOS2CodePageRanges, [0, 1]) def test_set_openTypeOS2CodePageRanges(self): - set_custom_params(self.ufo, parsed=[ - ('openTypeOS2CodePageRanges', [0, 1])]) + 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'} - set_custom_params(self.ufo, parsed=[('GASP Table', gasp_table)]) + self.font.customParameters['GASP Table'] = gasp_table + self.set_custom_params() ufo_range_records = self.ufo.info.openTypeGaspRangeRecords self.assertIsNotNone(ufo_range_records) @@ -372,17 +403,29 @@ def test_gasp_table(self): 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']) + 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): - set_custom_params(self.ufo, parsed=[('Disable Last Change', True)]) - self.assertEqual(True, - self.ufo.lib[GLYPHS_PREFIX + 'disablesLastChange']) + 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): - set_custom_params(self.ufo, parsed=[('xHeight', '500')]) - self.assertEqual(self.ufo.info.xHeight, 500) + 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: check that the instance custom param wins over the + # interpolated value def test_replace_feature(self): self.ufo.features.text = dedent(""" @@ -402,7 +445,8 @@ def test_replace_feature(self): repl = "liga; sub f f by ff;" - set_custom_params(self.ufo, parsed=[("Replace Feature", repl)]) + self.master.customParameters["Replace Feature"] = repl + self.set_custom_params() self.assertEqual(self.ufo.features.text, dedent(""" feature liga { @@ -422,14 +466,15 @@ def test_replace_feature(self): 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.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): - glyphs_param = ("Don't use Production Names", value) - set_custom_params(self.ufo, parsed=[glyphs_param]) + 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], @@ -792,26 +837,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 +851,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) @@ -962,19 +987,21 @@ def test_lib_width(self): def test_lib_no_custom(self): font = generate_minimal_font() ufo = to_ufos(font)[0] - self.assertFalse(GLYPHS_PREFIX + 'customName' in ufo.lib) + self.assertFalse(MASTER_CUSTOM_PARAM_PREFIX + 'customName' in ufo.lib) def test_lib_custom(self): font = generate_minimal_font() font.masters[0].customName = 'FooBar' ufo = to_ufos(font)[0] - self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'customName'], 'FooBar') + self.assertEqual( + ufo.lib[MASTER_CUSTOM_PARAM_PREFIX + 'customName'], 'FooBar') 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 +1011,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 +1082,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 +1101,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 +1124,11 @@ 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}, {}]) class _PointDataPen(object): @@ -1111,17 +1150,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 +1184,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 +1196,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 +1207,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 +1221,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 +1231,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 +1317,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/classes_test.py b/tests/classes_test.py index 6d9a938fb..ea2c27f61 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__()) @@ -961,7 +962,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 +1070,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 +1115,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 +1232,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 +1244,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 +1349,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 +1404,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 +1481,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/DesignspaceTestTwoAxes.designspace b/tests/data/DesignspaceTestTwoAxes.designspace index 80938c14a..9d858f89c 100644 --- a/tests/data/DesignspaceTestTwoAxes.designspace +++ b/tests/data/DesignspaceTestTwoAxes.designspace @@ -8,9 +8,9 @@ Weight - + - + Width 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 index 4cf222c3c..63aa75969 100644 --- a/tests/interpolation_test.py +++ b/tests/interpolation_test.py @@ -27,7 +27,7 @@ import defcon from fontTools.misc.py23 import open from glyphsLib.builder.constants import GLYPHS_PREFIX -from glyphsLib.interpolation import ( +from glyphsLib.builder.interpolation import ( build_designspace, set_weight_class, set_width_class, build_stylemap_names ) from glyphsLib.classes import GSInstance, GSCustomParameter @@ -120,12 +120,6 @@ def expect_designspace(self, masters, instances, expectedFile): 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, diff --git a/tests/main_test.py b/tests/main_test.py index 30b477112..dc1b2f795 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -37,6 +37,6 @@ def test_parser_main(self): ['python', '-m', 'glyphsLib.parser', filename], universal_newlines=True) # Windows gives \r\n otherwise self.assertLinesEqual( - str(expected.splitlines()), - str(out.splitlines()), + list(map(str, expected.splitlines())), + list(map(str, out.splitlines())), 'The roundtrip should output the .glyphs file unmodified.') diff --git a/tests/parser_test.py b/tests/parser_test.py index 88172cc2e..d072bb509 100644 --- a/tests/parser_test.py +++ b/tests/parser_test.py @@ -24,7 +24,6 @@ from glyphsLib.parser import Parser from glyphsLib.classes import GSGlyph, GSLayer -from glyphsLib.types import color, glyphs_datetime from fontTools.misc.py23 import unicode GLYPH_DATA = '''\ @@ -121,34 +120,6 @@ def test_parse_dict_in_dict(self): ) -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, -} - - class ParserGlyphTest(unittest.TestCase): def test_parse_empty_glyphs(self): # data = '({glyphname="A";})' diff --git a/tests/roundtrip_test.py b/tests/roundtrip_test.py index 7bf42d39c..658e09db6 100644 --- a/tests/roundtrip_test.py +++ b/tests/roundtrip_test.py @@ -30,7 +30,6 @@ 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') with open(filename) as f: diff --git a/tests/run_roundtrip_on_noto.py b/tests/run_roundtrip_on_noto.py index 498881c1a..52ace0ef5 100644 --- a/tests/run_roundtrip_on_noto.py +++ b/tests/run_roundtrip_on_noto.py @@ -19,7 +19,7 @@ import test_helpers -NOTO_DIRECTORY = os.path.join(os.path.dirname(__file__), 'noto-source-moyogo') +NOTO_DIRECTORY = os.path.join(os.path.dirname(__file__), 'downloaded', 'noto-source-moyogo') NOTO_GIT_URL = "https://github.com/moyogo/noto-source.git" NOTO_GIT_BRANCH = "normalized-1071" 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..8c744a1f7 --- /dev/null +++ b/tests/run_various_tests_on_various_files.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. + +import subprocess +import os +import unittest +import re + +import test_helpers + +testables = [ + { + # Directory name inside `downloaded/` + 'directory': 'noto-source-moyogo', + 'git_url': 'https://github.com/moyogo/noto-source.git', + 'git_ref': 'normalized-1071' + }, + { + 'directory': 'montserrat', + 'git_url': 'https://github.com/JulietaUla/Montserrat', + 'git_ref': 'master', + }, +] + + +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 GlyphsOnlyRoundtripTests(unittest.TestCase, + test_helpers.AssertParseWriteRoundtrip): + pass + + +if __name__ == '__main__': + print("Run with `pytest -c noto_pytest.ini`") +else: + 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) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 8c867b771..d3128a96c 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -16,10 +16,15 @@ import difflib import sys +import os.path +import tempfile +from collections import OrderedDict from textwrap import dedent import glyphsLib -from glyphsLib.builder import to_glyphs, to_ufos +from glyphsLib.designSpaceDocument import (DesignSpaceDocument, + InMemoryDocWriter) +from glyphsLib.builder import to_glyphs, to_designspace from glyphsLib.writer import Writer from fontTools.misc.py23 import UnicodeIO @@ -70,11 +75,49 @@ def assertParseWriteRoundtrip(self, filename): expected, actual, "The writer should output exactly what the parser read") + class AssertUFORoundtrip(AssertLinesEqual): + 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) + + # 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') + designspace.write(path) + designspace_roundtrip = DesignSpaceDocument( + writerClass=InMemoryDocWriter) + 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") 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..968fd6f3a 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; } @@ -412,8 +415,6 @@ 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 = ( @@ -458,6 +459,8 @@ def test_write_instance(self): linkStyle = "linked style value"; manualInterpolation = 1; name = "SemiBoldCompressed (name)"; + weightClass = "SemiBold (weight)"; + widthClass = "Compressed (width)"; } """)) @@ -616,7 +619,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 +631,6 @@ def test_write_glyph(self): associatedMasterId = "MASTER-ID"; layerId = "LAYER-ID"; name = L1; - width = 0; } ); leftKerningGroup = A; @@ -731,9 +733,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 @@ -758,7 +758,6 @@ def test_write_layer(self): ); annotations = ( { - position = ; text = "Fuck, this curve is ugly!"; type = 1; } @@ -812,7 +811,7 @@ def test_write_layer(self): self.assertNotIn('name = "";', 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; @@ -824,7 +823,7 @@ 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 +896,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 +916,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 +928,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 +941,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 +956,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 +977,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 @@ -1030,10 +1029,10 @@ def test_write_background_image(self): # 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 +1042,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}"; } """)) From 3c2910170852d64e7d86cabfbbfa7aef47ad7b2c Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 22 Nov 2017 18:36:45 +0000 Subject: [PATCH 02/44] Improve the integration test script (Noto, Montserrat and others) --- Lib/glyphsLib/__init__.py | 2 +- Lib/glyphsLib/builder/builders.py | 8 +- Lib/glyphsLib/classes.py | 6 +- Lib/glyphsLib/designSpaceDocument.py | 4 +- tests/run_roundtrip_on_noto.py | 69 --------- tests/run_various_tests_on_various_files.py | 161 +++++++++++++++++--- tests/test_helpers.py | 12 ++ tests/writer_test.py | 6 + 8 files changed, 170 insertions(+), 98 deletions(-) delete mode 100644 tests/run_roundtrip_on_noto.py diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index c358ccbc6..b220b6910 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -23,7 +23,7 @@ from glyphsLib.classes import __all__ as __all_classes__ from glyphsLib.classes import * -from glyphsLib.builder import to_ufos +from glyphsLib.builder import to_ufos, to_designspace, to_glyphs from glyphsLib.builder.interpolation import interpolate, build_designspace from glyphsLib.parser import load, loads from glyphsLib.writer import dump, dumps diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index dcfe5a067..856b8b964 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -298,7 +298,13 @@ def __init__(self, ufos=[], designspace=None, glyphs_module=classes): # assert all(ufo in designspace.getFonts() for ufo in ufos) self.ufos = ufos else: - self.ufos = [source.font for source in designspace.sources] + self.ufos = [] + for source in designspace.sources: + try: + # It's an in-memory source descriptor + self.ufos.append(source.font) + except AttributeError: + self.ufos.append(designspace.fontClass(source.path)) elif ufos: self.designspace = None self.ufos = ufos diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index f4d70fe5a..4f220334c 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -2445,8 +2445,8 @@ class GSLayer(GSBase): "widthMetricsKey": unicode, } _defaultsForName = { - "width": 0, # FIXME: (jany) check in glyphs - "weight": 600, + "width": 600.0, + "weight": 600.0, "leftMetricsKey": None, "rightMetricsKey": None, "widthMetricsKey": None, @@ -2540,6 +2540,8 @@ 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": diff --git a/Lib/glyphsLib/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py index 24de39744..76949cc70 100644 --- a/Lib/glyphsLib/designSpaceDocument.py +++ b/Lib/glyphsLib/designSpaceDocument.py @@ -1101,7 +1101,9 @@ def getFonts(self): # so someone can build a thing for a thing. fonts = [] for sourceDescriptor in self.sources: - if sourceDescriptor.path is not None: + if sourceDescriptor.font is not None: + fonts.append(sourceDescriptor.font) + elif sourceDescriptor.path is not None: if os.path.exists(sourceDescriptor.path): f = self.fontClass(sourceDescriptor.path) fonts.append((f, sourceDescriptor.location)) diff --git a/tests/run_roundtrip_on_noto.py b/tests/run_roundtrip_on_noto.py deleted file mode 100644 index 52ace0ef5..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__), 'downloaded', '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 index 8c744a1f7..95e6ccf9c 100644 --- a/tests/run_various_tests_on_various_files.py +++ b/tests/run_various_tests_on_various_files.py @@ -15,21 +15,127 @@ import subprocess import os import unittest +import pytest import re +import glyphsLib +from glyphsLib.designSpaceDocument import DesignSpaceDocument import test_helpers -testables = [ +# 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 = 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'], 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 = 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'], 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 the whole chain from designspace + UFOs to .glyphs and back""" + + @classmethod + def add_tests(cls, testable): + files = designspace_files(directory(testable)) + for index, filename in enumerate(sorted(files)): + + def test_method(self, filename=filename): + doc = DesignSpaceDocument() + 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) + + +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 { - # Directory name inside `downloaded/` - 'directory': 'noto-source-moyogo', + 'name': 'noto_moyogo', # dirname inside `downloaded/` 'git_url': 'https://github.com/moyogo/noto-source.git', - 'git_ref': 'normalized-1071' + 'git_ref': 'normalized-1071', + 'classes': (GlyphsRT, GlyphsToDesignspaceRT), }, { - 'directory': 'montserrat', + # 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), + }, + # 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), }, ] @@ -53,25 +159,32 @@ def app_version(filename): return "no_version" -class GlyphsOnlyRoundtripTests(unittest.TestCase, - test_helpers.AssertParseWriteRoundtrip): - pass +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) -if __name__ == '__main__': - print("Run with `pytest -c noto_pytest.ini`") -else: - subprocess.call(["git", "clone", NOTO_GIT_URL, NOTO_DIRECTORY]) +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", 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) + ["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([__file__, *sys.argv])) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d3128a96c..a5eae79b8 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -121,3 +121,15 @@ def assertUFORoundtrip(self, font): self.assertLinesEqual( expected, actual, "The font should not be modified by the roundtrip") + + +class AssertDesignspaceRoundtrip(object): + def assertDesignspaceRoundtrip(self, designspace): + font = to_glyphs(designspace) + font.save('test_font.glyphs') + # roundtrip_in_mem = to_designspace(font) + # # TODO: tempdir + # font.save('lol.glyphs') + # font_rt = GSFont('lol.glyphs') + # roundtrip = to_designspace(font_rt) + # # TODO: assert designspace + UFOS are equal! diff --git a/tests/writer_test.py b/tests/writer_test.py index 968fd6f3a..1366fe9f6 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -631,6 +631,7 @@ def test_write_glyph(self): associatedMasterId = "MASTER-ID"; layerId = "LAYER-ID"; name = L1; + width = 600; } ); leftKerningGroup = A; @@ -810,6 +811,11 @@ 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)) self.assertWrites(anchor, dedent("""\ From 03f34d3515c2abefd71453f567a6290a0473b702 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Fri, 24 Nov 2017 12:55:02 +0000 Subject: [PATCH 03/44] Simplify custom params --- Lib/glyphsLib/builder/builders.py | 3 +- Lib/glyphsLib/builder/custom_params.py | 75 +++++--------------------- Lib/glyphsLib/builder/font.py | 5 +- Lib/glyphsLib/builder/interpolation.py | 1 + tests/builder_test.py | 1 + 5 files changed, 20 insertions(+), 65 deletions(-) diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 856b8b964..d0bbfb734 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -365,8 +365,7 @@ def font(self): 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_family_custom_params, - to_glyphs_master_custom_params) + 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 diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 4ad8c3cba..cea681bf5 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -138,14 +138,6 @@ def unhandled_lib_items(self): class AbstractParamHandler(object): - # @abstractmethod - def glyphs_names(self): - return [] - - # @abstractmethod - def ufo_names(self): - return [] - # @abstractmethod def to_glyphs(self): pass @@ -173,19 +165,6 @@ def __init__(self, glyphs_name, ufo_name=None, self.value_to_ufo = value_to_ufo self.value_to_glyphs = value_to_glyphs - def glyphs_names(self): - """Return the list of names that are handled - from the customParameters. - """ - # Just in case one handler covers several names - if self.glyphs_long_name: - return (self.glyphs_name, self.glyphs_long_name) - return (self.glyphs_name,) - - def ufo_names(self): - """Return the list of names that are handled from the lib.plist.""" - return (self.ufo_name,) - # 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 @@ -249,14 +228,11 @@ def _write_to_ufo(self, glyphs, ufo, value): KNOWN_PARAM_HANDLERS = [] -KNOWN_PARAM_GLYPHS_NAMES = set() -KNOWN_PARAM_UFO_NAMES = set() def register(handler): KNOWN_PARAM_HANDLERS.append(handler) - KNOWN_PARAM_GLYPHS_NAMES.update(handler.glyphs_names()) - KNOWN_PARAM_UFO_NAMES.update(handler.ufo_names()) + GLYPHS_UFO_CUSTOM_PARAMS = ( ('hheaAscender', 'openTypeHheaAscender'), @@ -369,6 +345,7 @@ def to_glyphs_gasp_table(value): class MiscParamHandler(ParamHandler): + """Copy GSFont attributes to ufo lib""" def _read_from_glyphs(self, glyphs): return glyphs.get_attribute_value(self.glyphs_name) @@ -402,12 +379,6 @@ class OS2SelectionParamHandler(AbstractParamHandler): ('Use Typo Metrics', 7), ) - def glyphs_names(self): - return [flag[0] for flag in self.flags] - - def ufo_names(self): - return ('openTypeOS2Selection',) - def to_glyphs(self, glyphs, ufo): ufo_flags = ufo.get_info_value('openTypeOS2Selection') if ufo_flags is None: @@ -477,50 +448,30 @@ def to_glyphs(self, glyphs, ufo): register(ReplaceFeatureParamHandler()) -def to_ufo_custom_params(self, ufo, master): +def to_ufo_custom_params(self, ufo, glyphs_object): # glyphs_module=None because we shouldn't instanciate any Glyphs classes - font_proxy = GlyphsObjectProxy(self.font, glyphs_module=None) - master_proxy = GlyphsObjectProxy(master, glyphs_module=None) + glyphs_proxy = GlyphsObjectProxy(glyphs_object, glyphs_module=None) ufo_proxy = UFOProxy(ufo) for handler in KNOWN_PARAM_HANDLERS: - handler.to_ufo(font_proxy, ufo_proxy) - handler.to_ufo(master_proxy, ufo_proxy) + handler.to_ufo(glyphs_proxy, ufo_proxy) - for param in font_proxy.unhandled_custom_parameters(): + for param in glyphs_proxy.unhandled_custom_parameters(): name = _normalize_custom_param_name(param.name) - ufo.lib[CUSTOM_PARAM_PREFIX + font_proxy.sub_key + name] = param.value - for param in master_proxy.unhandled_custom_parameters(): - name = _normalize_custom_param_name(param.name) - ufo.lib[CUSTOM_PARAM_PREFIX + master_proxy.sub_key + name] = param.value + ufo.lib[CUSTOM_PARAM_PREFIX + glyphs_proxy.sub_key + name] = param.value _set_default_params(ufo) -def to_glyphs_family_custom_params(self, ufo): - font_proxy = GlyphsObjectProxy(self.font, glyphs_module=self.glyphs_module) +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(font_proxy, ufo_proxy) - - _to_glyphs_unknown_parameters(font_proxy, ufo_proxy) - - _unset_default_params(self.font) - -def to_glyphs_master_custom_params(self, ufo, master): - master_proxy = GlyphsObjectProxy(master, glyphs_module=self.glyphs_module) - ufo_proxy = UFOProxy(ufo) + # Handle known parameters for handler in KNOWN_PARAM_HANDLERS: - handler.to_glyphs(master_proxy, ufo_proxy) - - _to_glyphs_unknown_parameters(master_proxy, ufo_proxy) + handler.to_glyphs(glyphs_proxy, ufo_proxy) - _unset_default_params(master) - - -def _to_glyphs_unknown_parameters(glyphs_proxy, ufo_proxy): # TODO: (jany) Make sure that all parameters of the UFO info have a handler # That way, only lib can have extra stuff prefix = CUSTOM_PARAM_PREFIX + glyphs_proxy.sub_key @@ -531,6 +482,8 @@ def _to_glyphs_unknown_parameters(glyphs_proxy, ufo_proxy): name = name[len(prefix):] glyphs_proxy.set_custom_value(name, value) + _unset_default_params(glyphs_object) + def _normalize_custom_param_name(name): """Replace curved quotes with straight quotes in a custom parameter name. diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index d1a953428..a8822d784 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -111,6 +111,7 @@ def to_ufo_font_attributes(self, family_name): 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, font) self.to_ufo_custom_params(ufo, master) master_id = master.id @@ -175,7 +176,7 @@ def _set_glyphs_font_attributes(self, ufo): self.to_glyphs_family_names(ufo) self.to_glyphs_family_user_data(ufo) - self.to_glyphs_family_custom_params(ufo) + self.to_glyphs_custom_params(ufo, font) self.to_glyphs_features(ufo) @@ -227,7 +228,7 @@ def _set_glyphs_master_attributes(self, 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_master_custom_params(ufo, master) + self.to_glyphs_custom_params(ufo, master) def to_glyphs_ordered_masters(self): diff --git a/Lib/glyphsLib/builder/interpolation.py b/Lib/glyphsLib/builder/interpolation.py index 611a79c1d..234970cf1 100644 --- a/Lib/glyphsLib/builder/interpolation.py +++ b/Lib/glyphsLib/builder/interpolation.py @@ -385,6 +385,7 @@ def apply_instance_data(instance_data): set_weight_class(ufo, data) set_width_class(ufo, data) self = UFOBuilder(instance_data, defcon) + # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? to_ufo_custom_params(self, ufo, data) ufo.save() instance_ufos.append(ufo) diff --git a/tests/builder_test.py b/tests/builder_test.py index b3ceec9ac..338ec8aad 100644 --- a/tests/builder_test.py +++ b/tests/builder_test.py @@ -287,6 +287,7 @@ def setUp(self): 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): From d6e832d4f056c5369ee7421154322643dc2c3c68 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Fri, 24 Nov 2017 18:33:08 +0000 Subject: [PATCH 04/44] Fix some Glyphs-Glyphs rt issues --- Lib/glyphsLib/classes.py | 5 +++++ tests/parser_test.py | 3 +-- tests/run_various_tests_on_various_files.py | 13 ++++++++++--- tests/test_helpers.py | 6 ++++++ tests/writer_test.py | 10 ++++++++++ 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 4f220334c..5dfb0ee75 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -1901,6 +1901,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 diff --git a/tests/parser_test.py b/tests/parser_test.py index d072bb509..de695ca73 100644 --- a/tests/parser_test.py +++ b/tests/parser_test.py @@ -23,8 +23,7 @@ import datetime from glyphsLib.parser import Parser -from glyphsLib.classes import GSGlyph, GSLayer -from fontTools.misc.py23 import unicode +from glyphsLib.classes import GSGlyph GLYPH_DATA = '''\ ( diff --git a/tests/run_various_tests_on_various_files.py b/tests/run_various_tests_on_various_files.py index 95e6ccf9c..1ff790c23 100644 --- a/tests/run_various_tests_on_various_files.py +++ b/tests/run_various_tests_on_various_files.py @@ -117,11 +117,18 @@ def add_tests(cls, testable): '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', - 'git_url': 'https://github.com/phoikoi/VT323', - 'git_ref': 'master', + 'name': 'vt323_jany', + 'git_url': 'https://github.com/belluzj/VT323', + 'git_ref': 'glyphs-1089', 'classes': (GlyphsRT, GlyphsToDesignspaceRT), }, # The following contain .designspace files diff --git a/tests/test_helpers.py b/tests/test_helpers.py index a5eae79b8..09bca6743 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -67,6 +67,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, diff --git a/tests/writer_test.py b/tests/writer_test.py index 1366fe9f6..06300dfe4 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -755,6 +755,7 @@ def test_write_layer(self): anchors = ( { name = top; + position = "{0, 0}"; } ); annotations = ( @@ -825,6 +826,15 @@ 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 From 03ea2c2275221bfa6db664f2a3d30fffd213dc90 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 7 Dec 2017 13:04:04 +0000 Subject: [PATCH 05/44] Sort out the userData/lib stuff + other fixes Start testing designspace -> designspace roundtrip --- Lib/glyphsLib/builder/__init__.py | 32 ++- Lib/glyphsLib/builder/builders.py | 131 ++++++++--- Lib/glyphsLib/builder/custom_params.py | 6 +- Lib/glyphsLib/builder/features.py | 15 +- Lib/glyphsLib/builder/font.py | 98 +------- Lib/glyphsLib/builder/glyph.py | 4 +- Lib/glyphsLib/builder/instances.py | 37 +-- Lib/glyphsLib/builder/kerning.py | 14 +- Lib/glyphsLib/builder/masters.py | 170 ++++++++++++++ Lib/glyphsLib/builder/names.py | 3 +- Lib/glyphsLib/builder/user_data.py | 135 +++++++---- Lib/glyphsLib/classes.py | 22 +- Lib/glyphsLib/designSpaceDocument.py | 80 +++++-- setup.py | 1 + tests/all_to_ufos.py | 52 +++++ tests/lib_and_user_data.png | Bin 0 -> 51696 bytes tests/lib_and_user_data.uml | 96 ++++++++ tests/lib_and_user_data_test.py | 245 ++++++++++++++++++++ tests/run_various_tests_on_various_files.py | 51 ++-- tests/test_helpers.py | 120 ++++++++-- tests/writer_test.py | 1 + tox.ini | 1 + 22 files changed, 1044 insertions(+), 270 deletions(-) create mode 100644 Lib/glyphsLib/builder/masters.py create mode 100644 tests/all_to_ufos.py create mode 100644 tests/lib_and_user_data.png create mode 100644 tests/lib_and_user_data.uml create mode 100644 tests/lib_and_user_data_test.py diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index 5bcdf549b..e27ca485e 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -25,8 +25,12 @@ logger = logging.getLogger(__name__) -def to_ufos(font, include_instances=False, family_name=None, - propagate_anchors=True, ufo_module=defcon): +def to_ufos(font, + include_instances=False, + family_name=None, + propagate_anchors=True, + ufo_module=defcon, + minimize_glyphs_diffs=False): # TODO: (jany) Update documentation """Take .glyphs file data and load it into UFOs. @@ -42,7 +46,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) @@ -51,8 +56,11 @@ def to_ufos(font, include_instances=False, family_name=None, return result -def to_designspace(font, family_name=None, propagate_anchors=True, - ufo_module=defcon): +def to_designspace(font, + family_name=None, + propagate_anchors=True, + ufo_module=defcon, + minimize_glyphs_diffs=False): # TODO: (jany) Update documentation """Take .glyphs file data and load it into a Designspace Document + UFOS. @@ -68,11 +76,15 @@ def to_designspace(font, family_name=None, propagate_anchors=True, font, ufo_module=ufo_module, family_name=family_name, - propagate_anchors=propagate_anchors) + 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): +def to_glyphs(ufos_or_designspace, + glyphs_module=classes, + minimize_ufo_diffs=False): """ Take a list of UFOs and combine them into a single .glyphs file. @@ -83,8 +95,10 @@ def to_glyphs(ufos_or_designspace, glyphs_module=classes): # FIXME: (jany) duck-type instead of isinstance if isinstance(ufos_or_designspace, DesignSpaceDocument): builder = GlyphsBuilder(designspace=ufos_or_designspace, - glyphs_module=glyphs_module) + glyphs_module=glyphs_module, + minimize_ufo_diffs=minimize_ufo_diffs) else: builder = GlyphsBuilder(ufos=ufos_or_designspace, - glyphs_module=glyphs_module) + glyphs_module=glyphs_module, + minimize_ufo_diffs=minimize_ufo_diffs) return builder.font diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index d0bbfb734..7bee6f89b 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -51,7 +51,9 @@ def __init__(self, ufo_module=defcon, designspace_module=designSpaceDocument, family_name=None, - propagate_anchors=True): + propagate_anchors=True, + use_designspace=False, + minimize_glyphs_diffs=False): """Create a builder that goes from Glyphs to UFO + designspace. Keyword arguments: @@ -64,10 +66,18 @@ def __init__(self, family_name -- if provided, the master UFOs will be given this name and only instances with this name will be returned. 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.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, # indexed by master ID, the same order as masters in the source GSFont. @@ -100,8 +110,6 @@ def __init__(self, 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. @@ -137,8 +145,8 @@ def masters(self): if assoc_id != layer.layerId: # 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((assoc_id, glyph_name, + layer_name, layer)) continue ufo = self._ufos[layer_id] @@ -150,14 +158,14 @@ def masters(self): for master_id, glyph_name, layer_name, layer \ in supplementary_layer_data: - if (layer.layerId not in master_layer_ids and - layer.associatedMasterId not in master_layer_ids): + 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.layerId)) + 'with an actual master.'.format(self.font.familyName, + glyph_name, layer.layerId)) continue if not layer_name: @@ -184,6 +192,8 @@ def masters(self): self.to_ufo_propagate_font_anchors(ufo) self.to_ufo_features(ufo) # This depends on the glyphOrder key self.to_ufo_kerning_groups(ufo, kerning_groups) + for layer in ufo.layers: + self.to_ufo_layer_lib(layer) for master_id, kerning in self.font.kerning.items(): self.to_ufo_kerning(self._ufos[master_id], kerning) @@ -215,7 +225,8 @@ def designspace(self): self._designspace = self.designspace_module.DesignSpaceDocument( writerClass=designSpaceDocument.InMemoryDocWriter, fontClass=self.ufo_module.Font) - self.to_ufo_instances() + self.to_designspace_instances() + self.to_designspace_family_user_data() return self._designspace # DEPRECATED @@ -252,14 +263,16 @@ def instance_data(self): from .glyph import to_ufo_glyph, to_ufo_glyph_background from .guidelines import to_ufo_guidelines from .hints import to_ufo_hints - from .instances import to_ufo_instances + from .instances import to_designspace_instances from .kerning import (to_ufo_kerning, to_ufo_glyph_groups, to_ufo_kerning_groups) + from .masters import to_ufo_master_attributes from .names import to_ufo_names from .paths import to_ufo_paths - from .user_data import (to_ufo_family_user_data, to_ufo_master_user_data, - to_ufo_glyph_user_data, to_ufo_layer_user_data, - to_ufo_node_user_data) + 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): @@ -279,7 +292,11 @@ def filter_instances_by_family(instances, family_name=None): 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. Keyword arguments: @@ -291,7 +308,13 @@ 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.glyphs_module = glyphs_module + self.minimize_ufo_diffs = minimize_ufo_diffs + if designspace is not None: self.designspace = designspace if ufos: @@ -300,18 +323,17 @@ def __init__(self, ufos=[], designspace=None, glyphs_module=classes): else: self.ufos = [] for source in designspace.sources: - try: - # It's an in-memory source descriptor - self.ufos.append(source.font) - except AttributeError: - self.ufos.append(designspace.fontClass(source.path)) + # FIXME: (jany) Do something better for the InMemory stuff + # Is it an in-memory source descriptor? + if not hasattr(source, 'font'): + source.font = designspace.fontClass(source.path) + self.ufos.append(source.font) elif ufos: - self.designspace = None + self.designspace = self._fake_designspace(ufos) self.ufos = ufos else: raise RuntimeError( 'Please provide a designspace or at least one UFO.') - self.glyphs_module = glyphs_module self._font = None """The GSFont that will be built.""" @@ -332,9 +354,11 @@ def font(self): master = self.glyphs_module.GSFontMaster() self.to_glyphs_font_attributes(ufo, master, is_initial=(index == 0)) + self.to_glyphs_master_attributes(ufo, master) self._font.masters.insert(len(self._font.masters), master) for layer in ufo.layers: + self.to_glyphs_layer_lib(layer) for glyph in layer: self.to_glyphs_glyph(glyph, layer, master) self.to_glyphs_glyph_groups(kerning_groups, glyph) @@ -342,22 +366,66 @@ def font(self): self.to_glyphs_kerning(ufo, master) # Now that all GSGlyph are built, restore the glyph order - first_ufo = next(iter(self.ufos)) - 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)) + for first_ufo in self.ufos: + if GLYPH_ORDER_KEY in first_ufo.lib: + glyph_order = first_ufo.lib[GLYPH_ORDER_KEY] + lookup = {name: i for i, name in enumerate(glyph_order)} + self.font.glyphs = sorted( + self.font.glyphs, + key=lambda glyph: lookup.get(glyph.name, 1 << 63)) + + # FIXME: (jany) Only do that on the first one. Maybe we should + # merge the various `public.glyphorder` values? + break # Restore the layer ordering in each glyph for glyph in self._font.glyphs: self.to_glyphs_layer_order(glyph) + self.to_glyphs_family_user_data_from_designspace() self.to_glyphs_instances() return self._font + 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 = designSpaceDocument.DesignSpaceDocument( + writerClass=designSpaceDocument.InMemoryDocWriter) + + 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 + + # MutatorMath.DesignSpaceDocumentWriter iterates over the location + # dictionary, which is non-deterministic so it can cause test failures. + # We therefore use an OrderedDict to which we insert in axis order. + # Since glyphsLib will switch to DesignSpaceDocument once that is + # integrated into fonttools, it's not worth fixing upstream. + # https://github.com/googlei18n/glyphsLib/issues/165 + # FIXME: (jany) still needed? + # location = OrderedDict() + # for axis in self.designspace.axes: + # value_key = axis.name + 'Value' + # if axis.name.startswith('custom'): + # # FIXME: (jany) this is getting boring + # value_key = 'customValue' + axis.name[len('custom'):] + # location[axis.name] = ufo.lib.get( + # MASTER_CUSTOM_PARAM_PREFIX + value_key, DEFAULT_LOCS[axis.name]) + source.location = {} + # if font is regular: + # source.copyLib = True + # source.copyInfo = True + # source.copyGroups = True + # source.copyFeatures = True + 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 @@ -375,10 +443,13 @@ def font(self): from .kerning import (to_glyphs_glyph_groups, to_glyphs_kerning_groups, 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 .user_data import (to_glyphs_family_user_data, + 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) diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index cea681bf5..2f4091a41 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -276,7 +276,8 @@ def register(handler): glyphs_name='codePageRanges', ufo_name='openTypeOS2CodePageRanges', value_to_ufo=lambda value: [CODEPAGE_RANGES[v] for v in value], - value_to_glyphs=lambda value: [REVERSE_CODEPAGE_RANGES[v] for v in value] + # TODO: 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( @@ -285,7 +286,8 @@ def register(handler): # 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: None + value_to_glyphs=lambda value: value # TODO: 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 diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index b8a94667f..57f5a3a35 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -190,9 +190,18 @@ def _build_end_locations(self): # need the end location to find the text in between. # FIXME: (jany) maybe feaLib could provide that? self._build_end_locations_rec(self._doc) + + # TODO: (jany) add a test with a complex feature file (nested blocks) if self._doc.statements: - self._doc.statements[-1].end_location = ( - None, len(self._lines) + 1, len(self._lines[-1]) + 1) + end_location = (None, len(self._lines) + 1, + len(self._lines[-1]) + 1) + last_statement = self._doc.statements[-1] + while True: + last_statement.end_location = end_location + if (not hasattr(last_statement, 'statements') or + not last_statement.statements): + break + last_statement = last_statement.statements[-1] def _build_end_locations_rec(self, block): # To get the end location, we do a depth-first exploration of the ast: @@ -340,7 +349,7 @@ def _process_glyph_class_definition(self): automatic = False st = self.statements.peek() if isinstance(st, ast.Comment): - if self.AUTOMATIC_RE.match(st): + if self.AUTOMATIC_RE.match(st.text): automatic = True st = self.statements.peek(1) else: diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index a8822d784..f6b8f67c1 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -25,7 +25,6 @@ APP_VERSION_LIB_KEY = GLYPHS_PREFIX + 'appVersion' KEYBOARD_INCREMENT_KEY = GLYPHS_PREFIX + 'keyboardIncrement' -MASTER_ID_LIB_KEY = GLYPHS_PREFIX + 'fontMasterID' MASTER_ORDER_LIB_KEY = GLYPHS_PREFIX + 'fontMasterOrder' @@ -75,50 +74,17 @@ 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, font) - self.to_ufo_custom_params(ufo, master) - master_id = master.id - ufo.lib[MASTER_ID_LIB_KEY] = master_id + self.to_ufo_master_attributes(ufo, master) + ufo.lib[MASTER_ORDER_LIB_KEY] = index # FIXME: (jany) in the future, yield this UFO (for memory, lazy iter) - self._ufos[master_id] = ufo + self._ufos[master.id] = ufo def to_glyphs_font_attributes(self, ufo, master, is_initial): @@ -141,7 +107,6 @@ def to_glyphs_font_attributes(self, ufo, master, is_initial): else: # self._compare_and_merge_glyphs_font_attributes(ufo) pass - _set_glyphs_master_attributes(self, ufo, master) def _set_glyphs_font_attributes(self, ufo): @@ -175,69 +140,20 @@ def _set_glyphs_font_attributes(self, ufo): font.manufacturerURL = info.openTypeNameManufacturerURL self.to_glyphs_family_names(ufo) - self.to_glyphs_family_user_data(ufo) + self.to_glyphs_family_user_data_from_ufo(ufo) self.to_glyphs_custom_params(ufo, font) self.to_glyphs_features(ufo) -def _set_glyphs_master_attributes(self, ufo, master): - try: - master.id = ufo.lib[MASTER_ID_LIB_KEY] - except KeyError: - pass - - 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 - - try: - master.width = ufo.lib[GLYPHS_PREFIX + 'width'] - except KeyError: - pass - try: - master.weight = ufo.lib[GLYPHS_PREFIX + 'weight'] - except KeyError: - pass - - for number in ('', '1', '2', '3'): - name_key = GLYPHS_PREFIX + 'customName' + number - if name_key in ufo.lib: - custom_name = ufo.lib[name_key] - if custom_name: - setattr(master, 'customName' + number, custom_name) - value_key = GLYPHS_PREFIX + 'customValue' + number - if value_key in ufo.lib: - custom_value = ufo.lib[value_key] - if custom_value: - setattr(master, 'customValue' + number, custom_value) - - self.to_glyphs_blue_values(ufo, master) - self.to_glyphs_master_names(ufo, master) - self.to_glyphs_master_user_data(ufo, master) - self.to_glyphs_guidelines(ufo, master) - self.to_glyphs_custom_params(ufo, master) - - def to_glyphs_ordered_masters(self): """Modify in-place the list of UFOs to restore their original order.""" self.ufos = sorted(self.ufos, key=_original_master_order) def _original_master_order(ufo): + # FIXME: (jany) Here we should rely on order of sources in designspace + # if self.use_designspace try: return ufo.lib[MASTER_ORDER_LIB_KEY] - except: + except KeyError: return float('infinity') diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index e6bde7799..5cb36ce0a 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -112,7 +112,7 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph): 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, glyph) + 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) @@ -211,7 +211,7 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): 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, glyph) + 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) diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index cb1302f3a..b9fd50857 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -18,9 +18,10 @@ from collections import OrderedDict from glyphsLib.util import build_ufo_path -from .constants import (GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX, - MASTER_CUSTOM_PARAM_PREFIX) +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 EXPORT_KEY = GLYPHS_PREFIX + 'export' WIDTH_KEY = GLYPHS_PREFIX + 'width' @@ -31,7 +32,7 @@ INSTANCE_INTERPOLATIONS_KEY = GLYPHS_PREFIX + 'intanceInterpolations' -def to_ufo_instances(self): +def to_designspace_instances(self): """Write instance data from self.font to self.designspace.""" # base_family = masters[0].info.familyName @@ -47,18 +48,20 @@ def to_ufo_instances(self): # instances = list(filter(is_instance_active, instance_data.get('data', []))) ufo_masters = list(self.masters) - varfont_origin = _get_varfont_origin(ufo_masters) - regular = _find_regular_master(ufo_masters, regularName=varfont_origin) - _to_ufo_designspace_axes(self, regular) - _to_ufo_designspace_sources(self, regular) + if ufo_masters: + varfont_origin = _get_varfont_origin(ufo_masters) + regular = _find_regular_master(ufo_masters, regularName=varfont_origin) + _to_designspace_axes(self, regular) + _to_designspace_sources(self, regular) for instance in self.font.instances: - _to_ufo_designspace_instance(self, instance) + _to_designspace_instance(self, instance) def _get_varfont_origin(masters): # the 'Variation Font Origin' is a font-wide custom parameter, thus it is # shared by all the master ufos; here we just get it from the first one + assert len(masters) > 0 varfont_origin_key = "Variation Font Origin" return masters[0].lib.get(FONT_CUSTOM_PARAM_PREFIX + varfont_origin_key) @@ -144,7 +147,7 @@ def find_base_style(masters): } -def _to_ufo_designspace_axes(self, regular_master): +def _to_designspace_axes(self, regular_master): # According to Georg Seifert, Glyphs 3 will have a better model # for describing variation axes. The plan is to store the axis # information globally in the Glyphs file. In addition to actual @@ -219,17 +222,20 @@ def _to_ufo_designspace_axes(self, regular_master): self.designspace.addAxis(axis) -def _to_ufo_designspace_sources(self, regular): +def _to_designspace_sources(self, regular): """Add master UFOs to the designspace document.""" # FIXME: (jany) maybe read data from the GSFontMasters directly? - for font in self.masters: + for master, font in zip(self.font.masters, self.masters): source = self.designspace.newSourceDescriptor() source.font = font source.familyName = font.info.familyName source.styleName = font.info.styleName source.name = '%s %s' % (source.familyName, source.styleName) - source.filename = build_ufo_path('.', source.familyName, - source.styleName) + if UFO_FILENAME_KEY in master.userData: + source.filename = master.userData[UFO_FILENAME_KEY] + else: + source.filename = build_ufo_path('.', source.familyName, + source.styleName) # MutatorMath.DesignSpaceDocumentWriter iterates over the location # dictionary, which is non-deterministic so it can cause test failures. @@ -255,7 +261,7 @@ def _to_ufo_designspace_sources(self, regular): self.designspace.addSource(source) -def _to_ufo_designspace_instance(self, instance): +def _to_designspace_instance(self, instance): ufo_instance = self.designspace.newInstanceDescriptor() for p in instance.customParameters: param, value = p.name, p.value @@ -370,8 +376,7 @@ def to_glyphs_instances(self): pass for axis in [ - 'weight', 'width', 'custom', 'custom1', 'custom2', 'custom3' - ]: + 'weight', 'width', 'custom', 'custom1', 'custom2', 'custom3']: # Retrieve the interpolation location try: loc = ufo_instance.location[axis] diff --git a/Lib/glyphsLib/builder/kerning.py b/Lib/glyphsLib/builder/kerning.py index 8c2cfd019..e9a21084a 100644 --- a/Lib/glyphsLib/builder/kerning.py +++ b/Lib/glyphsLib/builder/kerning.py @@ -40,14 +40,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) + # 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) + # logger.warn(warning_msg % right) + pass if left_is_class != right_is_class: if left_is_class: pair = (left, right, True) @@ -78,12 +80,16 @@ def _remove_rule_if_conflict(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) diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py new file mode 100644 index 000000000..12c519e3d --- /dev/null +++ b/Lib/glyphsLib/builder/masters.py @@ -0,0 +1,170 @@ +# 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 uuid +import os + +from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX + +MASTER_ID_LIB_KEY = GLYPHS_PREFIX + 'fontMasterID' +UFO_FILENAME_KEY = GLYPHLIB_PREFIX + 'ufoFilename' + + +def to_ufo_master_attributes(self, ufo, master): + 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 + + # 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? + width = master.width + widthValue = master.widthValue + weight = master.weight + weightValue = master.weightValue + if weight: + ufo.lib[GLYPHS_PREFIX + 'weight'] = weight + if weightValue is not None: + ufo.lib[GLYPHS_PREFIX + 'weightValue'] = weightValue + if width: + ufo.lib[GLYPHS_PREFIX + 'width'] = width + if widthValue: + ufo.lib[GLYPHS_PREFIX + 'widthValue'] = widthValue + 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 + + 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, ufo, master): + try: + master.id = ufo.lib[MASTER_ID_LIB_KEY] + except KeyError: + master.id = str(uuid.uuid4()) + + if 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 + + # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 + source = _get_designspace_source_for_ufo(self, ufo) + for axis in ['weight', 'width']: + # First, try the designspace + try: + # TODO: ??? name = source.lib[...] + # setattr(master, axis, name) + raise KeyError + except KeyError: + # Second, try the custom key + try: + setattr(master, axis, ufo.lib[GLYPHS_PREFIX + axis]) + except KeyError: + # FIXME: (jany) as last resort, use 400/700 as a location, + # from the weightClass/widthClass? + pass + + value_key = axis + 'Value' + # First, try the designspace + try: + loc = source.location[axis] + setattr(master, value_key, loc) + except KeyError: + # Second, try the custom key + try: + setattr(master, value_key, ufo.lib[GLYPHS_PREFIX + value_key]) + except KeyError: + # FIXME: (jany) as last resort, use 400/700 as a location, + # from the weightClass/widthClass? + pass + + for number in ('', '1', '2', '3'): + # For the custom locations, we need both the name and the value + # FIXME: (jany) not sure it's worth implementing if everything is going + # to change soon on Glyphs.app's side. + pass + # try: + # axis = 'custom' + number + # value_key = 'customValue' + number + # loc = source.location[axis] + # value_key = axis + 'Value' + # if axis.startswith('custom'): + # setattr(instance, value_key, loc) + # except KeyError: + # pass + + # name_key = GLYPHS_PREFIX + 'customName' + number + # if name_key in ufo.lib: + # custom_name = ufo.lib[name_key] + # if custom_name: + # setattr(master, 'customName' + number, custom_name) + # value_key = GLYPHS_PREFIX + 'customValue' + number + # if value_key in ufo.lib: + # custom_value = ufo.lib[value_key] + # if custom_value: + # setattr(master, 'customValue' + number, custom_value) + + self.to_glyphs_blue_values(ufo, master) + self.to_glyphs_master_names(ufo, master) + self.to_glyphs_master_user_data(ufo, master) + self.to_glyphs_guidelines(ufo, master) + self.to_glyphs_custom_params(ufo, master) + + +def _get_designspace_source_for_ufo(self, ufo): + for source in self.designspace.sources: + if source.font == ufo: + return source diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index d481b33cc..824c0579f 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -105,4 +105,5 @@ def to_glyphs_family_names(self, ufo): def to_glyphs_master_names(self, ufo, master): - pass + # ??? + master.name = ufo.info.styleName diff --git a/Lib/glyphsLib/builder/user_data.py b/Lib/glyphsLib/builder/user_data.py index 190e49014..63d09a3bc 100644 --- a/Lib/glyphsLib/builder/user_data.py +++ b/Lib/glyphsLib/builder/user_data.py @@ -15,46 +15,60 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -from .constants import GLYPHS_PREFIX, PUBLIC_PREFIX +import base64 +import os +import posixpath -MASTER_USER_DATA_KEY = GLYPHS_PREFIX + 'fontMaster.userData' -LAYER_USER_DATA_KEY = GLYPHS_PREFIX + 'layer.userData' -GLYPH_USER_DATA_KEY = GLYPHS_PREFIX + 'glyph.userData' -NODE_USER_DATA_KEY = GLYPHS_PREFIX + 'node.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: + self.designspace.lib.update(dict(self.font.userData)) 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(): - # FIXME: (jany) Should put a Glyphs prefix? - # FIXME: (jany) At least identify which stuff we have put in lib during - # the Glyphs->UFO so that we don't take it back into userData in - # the other direction. - 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 - if user_data: - data = {} - for key in user_data.keys(): - data[key] = user_data[key] - ufo.lib[MASTER_USER_DATA_KEY] = data + 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, glyph): - user_data = glyph.userData - if user_data: - ufo_glyph.lib[GLYPH_USER_DATA_KEY] = dict(user_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 - if user_data: - key = LAYER_USER_DATA_KEY + '.' + layer.layerId - ufo_glyph.lib[key] = dict(user_data) + 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): @@ -65,32 +79,68 @@ def to_ufo_node_user_data(self, ufo_glyph, node): ufo_glyph.lib[key] = dict(user_data) -def to_glyphs_family_user_data(self, ufo): - """Set the GSFont userData from the UFO family-wide 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 ufo.lib.items(): - if _user_data_was_originally_there_family_wide(key): + 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_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.""" - if MASTER_USER_DATA_KEY not in ufo.lib: - return - user_data = ufo.lib[MASTER_USER_DATA_KEY] - if user_data: - master.userData = user_data + """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, glyph): - if GLYPH_USER_DATA_KEY in ufo_glyph.lib: - glyph.userData = ufo_glyph.lib[GLYPH_USER_DATA_KEY] +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): - key = LAYER_USER_DATA_KEY + '.' + layer.layerId - if key in ufo_glyph.lib: - layer.userData = ufo_glyph.lib[key] + 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): @@ -100,6 +150,5 @@ def to_glyphs_node_user_data(self, ufo_glyph, node): node.userData = ufo_glyph.lib[key] -def _user_data_was_originally_there_family_wide(key): - # FIXME: (jany) Identify better which keys must be brought back? +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 5dfb0ee75..a3b78ca1a 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -443,7 +443,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 @@ -984,6 +985,8 @@ class UserDataProxy(Proxy): def __getitem__(self, key): if self._owner._userData is None: raise KeyError + # 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): @@ -1004,6 +1007,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 @@ -1116,8 +1121,8 @@ def setValue(self, value): class GSAlignmentZone(GSBase): - def __init__(self, pos=0, size=20): + super(GSAlignmentZone, self).__init__() self.position = pos self.size = size @@ -1276,6 +1281,8 @@ def shouldWriteValueForKey(self, key): @property def name(self): + # FIXME: (jany) this getter looks stupid, it never returns the value + # from self._name. TODO: test what Glyphs does and how this makes sense name = self.customParameters["Master Name"] if name is None: names = [self.weight, self.width] @@ -1296,7 +1303,12 @@ def name(self): @name.setter def name(self, value): - self._name = value + # FIXME: (jany) this is called during init while there are no + # customparameters defined yet and it crashes, so I added the if + # because during init it sets an empty string + if value: + self._name = value + # self.customParameters["Master Name"] = value customParameters = property( lambda self: CustomParametersProxy(self), @@ -1320,6 +1332,7 @@ class GSNode(GSBase): def __init__(self, position=(0, 0), nodetype=LINE, smooth=False, name=None): + super(GSNode, self).__init__() self.position = Point(position[0], position[1]) self.type = nodetype self.smooth = smooth @@ -1482,6 +1495,7 @@ class GSPath(GSBase): _parent = None def __init__(self): + super(GSPath, self).__init__() self._closed = True self.nodes = [] @@ -2779,7 +2793,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]` diff --git a/Lib/glyphsLib/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py index 76949cc70..e4cce59f6 100644 --- a/Lib/glyphsLib/designSpaceDocument.py +++ b/Lib/glyphsLib/designSpaceDocument.py @@ -43,6 +43,10 @@ def __str__(self): return repr(self.msg) + repr(self.obj) +class NoFontError(DesignSpaceDocumentError): + """Raised when a SourceDescriptor cannot be linked to a source UFO.""" + + def _indent(elem, whitespace=" ", level=0): # taken from http://effbot.org/zone/element-lib.htm#prettyprint i = "\n" + level * whitespace @@ -82,6 +86,7 @@ class SourceDescriptor(SimpleDescriptor): 'familyName', 'styleName'] def __init__(self): + self.document = None # a reference to the parent document self.filename = None # the original path as found in the document self.path = None # the absolute path, calculated from filename self.name = None @@ -332,6 +337,10 @@ def write(self, pretty=True): self.root.append(ET.Element("instances")) for instanceObject in self.documentObject.instances: self._addInstance(instanceObject) + + if self.documentObject.lib: + self._addLib(self.documentObject.lib) + if pretty: _indent(self.root, whitespace=self._whiteSpace) tree = ET.ElementTree(self.root) @@ -537,6 +546,12 @@ def _addSource(self, sourceObject): sourceElement.append(locationElement) self.root.findall('.sources')[0].append(sourceElement) + def _addLib(self, dict): + libElement = ET.Element('lib') + # TODO: (jany) PLIST I guess? + libElement.text = json.dumps(dict) + self.root.append(libElement) + def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): glyphElement = ET.Element('glyph') if data.get('mute'): @@ -588,6 +603,7 @@ def read(self): self.readRules() self.readSources() self.readInstances() + self.readLib() def getSourcePaths(self, makeGlyphs=True, makeKerning=True, makeInfo=True): paths = [] @@ -771,7 +787,7 @@ def readSources(self): for kerningElement in sourceElement.findall(".kerning"): if kerningElement.attrib.get('mute') == '1': sourceObject.muteKerning = True - self.documentObject.sources.append(sourceObject) + self.documentObject.addSource(sourceObject) def locationFromElement(self, element): elementLocation = None @@ -961,6 +977,12 @@ def readGlyphElement(self, glyphElement, instanceObject): glyphData['masters'] = glyphSources instanceObject.glyphs[glyphName] = glyphData + def readLib(self): + """ TODO: (jany) doc + """ + for libElement in self.root.findall(".lib"): + self.documentObject.lib = json.loads(libElement.text) + class DesignSpaceDocument(object): """ Read, write data from the designspace file""" @@ -974,6 +996,7 @@ def __init__(self, readerClass=None, writerClass=None, fontClass=None): self.rules = [] self.default = None # name of the default master self.defaultLoc = None + self.lib = {} # if readerClass is not None: self.readerClass = readerClass @@ -1046,7 +1069,7 @@ def updatePaths(self): """ - for descriptor in self.sources + self.instances: + for descriptor in list(self.sources) + self.instances: # check what the relative path really should be? expectedFilename = None if descriptor.path is not None and self.path is not None: @@ -1062,8 +1085,24 @@ def updatePaths(self): if descriptor.filename is not expectedFilename: descriptor.filename = expectedFilename + @property + def sources(self): + # Return an immutable list to force users to call `addSource` + # or the setter. This is because I want source descriptors to keep a + # reference to their parent for their `font` property. + # Maybe this is all too much and another design is needed + # (where source descriptors don't instanciate fonts) + return tuple(self._sources) + + @sources.setter + def sources(self, sources): + self._sources = list(sources) + for source in self._sources: + source.document = self + def addSource(self, sourceDescriptor): - self.sources.append(sourceDescriptor) + sourceDescriptor.document = self + self._sources.append(sourceDescriptor) def addInstance(self, instanceDescriptor): self.instances.append(instanceDescriptor) @@ -1356,8 +1395,8 @@ class InMemorySourceDescriptor(SourceDescriptor): """ def __init__(self, fontClass=None): - super(InMemorySourceDescriptor, self).__init__() self._font = None + super(InMemorySourceDescriptor, self).__init__() if fontClass is not None: self.fontClass = fontClass else: @@ -1369,9 +1408,14 @@ def font(self): if self._font is not None: return self._font - # FIXME: (jany) will there always be a path? if self.path: self._font = self.fontClass(self.path) + elif self.document and self.filename: + path = os.path.join(os.path.dirname(self.document), self.filename) + self._font = self.fontClass(path) + + if self._font is None: + raise NoFontError("") return self._font @@ -1400,20 +1444,26 @@ class InMemoryDocWriter(BaseDocWriter): @classmethod def getSourceDescriptor(cls, document): + # FIXME: (jany) settle on whether we want + # 1. descriptors to hold the fontClass + # 2. descriptors to refer to their parent document + # 3. another design (back to "dumb data bag" descriptors, no OOP) return cls.sourceDescriptorClass(fontClass=document.fontClass) def write(self, pretty=True): super(InMemoryDocWriter, self).write(pretty) - for sourceObject in self.documentObject.sources: - if not sourceObject.filename: - self.documentObject.logger.warn( - 'In-memory source font {font} not written to the disk ' - 'because its descriptor does not have a filename.'.format( - font=sourceObject.font)) - continue - path = os.path.join( - os.path.dirname(self.path), sourceObject.filename) - sourceObject.font.save(path) + # FIXME: (jany) think about a way of reliably writing the document and + # the UFOs next to each other in one function call + # for sourceObject in self.documentObject.sources: + # if not sourceObject.filename: + # self.documentObject.logger.warn( + # 'In-memory source font {font} not written to the disk ' + # 'because its descriptor does not have a filename.'.format( + # font=sourceObject.font)) + # continue + # path = os.path.join( + # os.path.dirname(self.path), sourceObject.filename) + # sourceObject.font.save(path) def rulesToFeature(doc, whiteSpace="\t", newLine="\n"): diff --git a/setup.py b/setup.py index 5339967ef..31bd57083 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') 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/lib_and_user_data.png b/tests/lib_and_user_data.png new file mode 100644 index 0000000000000000000000000000000000000000..7e7b40e90409b6d95a9a731a44bb180f4527d6e3 GIT binary patch literal 51696 zcmbrlbzD?!+cv6#sHBK=N;gPKONn$33>`yvH>gN=N|*EuNOyN5HRRBZv~;)^-uLsq z&+psc{%i9G3)ZZ;;;id9kMmq1N(xdKs6?ob9zDX4{wSgH=+P6WM~{%go<9NZR6$~s zfeVAPq^7g6oxO*(iK+7=DHB^0M?+^52!)Xcg}Jk{JwF?py|tmOvx|*2tFfI8Hai~) zFoL0{nx^xA?mv3uo))1lZ#VxDy&3Juk7BZu)}ln~${fGPXiR>*4r7%zc>K50>uyb> z{m&bOhNiDO>93b$Evcnc(hyUUWMSUk>r{M)Fr1zQsP{|sCv>`z{*SinyLH|bCFu5b zbf6=Mls{`z2tKEBu~;D~Nn_Fuey#7G-Y<~f$IH%og+W}#X@aBESHZ0!_q(Ewo}`6ncd#EKrtt{8F#X0adn+$H z{L94F?4vFvA$TdY2mN`*SK+V3Z$ZC7nkuiY%7y~hdf*{U+|5{y}TC}ySv z4M{rmJAbfk2BMtKFHOyEy?Y+-`dlk!*%JKBfa#?mA09d$3XA{q;2;GoW4kKK^t=?i zg62G7jEdT9Wp_kM*>m9&&`-QKxOn?acHLe%v(ID~G&KhW>2!!L#EeinFin~%a~&HR zU%6Wl$e9>AGoE>PS#`4L=z0l!LMq#{M0`9ct! zHj5*vP*{ZBIWNK=?}gr+2s2j0)7Kw5dDSY}_)a+g6}Rc#;&0s=0fbF&b@=uBmZ)N* zp(-cOsgiz%Sst5mvGx$u-dQ4K3kE8?&Bm{49!;*XBOxn}FHc*- z=?HRkIro=6rdUp7<*Dy@dt`BF+kn-7v#IF*gDiI}l#Z{iDbthcGD}y*T!XNck1Rvx z*$bl8rx&VNo`-yqA?ulA+g^g9L6w*Eb*w+5Ik+181GTKU1KCtem@NfSOXCvf>50bp zuigwSV27xgt#budQVI+5<@Ttb#-863C_Odv=1d(Xpe<;(n@>?9@`y^Gee~$lBWVdy zHTSt+=`Y-;)UJDCo>b$(4E_X@2Koer20x*!z`CJKWqtzJ`mB{y5zHYc>5Bgz6dx{OX=p;`r`e|dQsUZPD$L&Fyzk}O20kQO|YewiJHzkQwDxEG9>5H+=aH@~%U z;wDRlijIzsh87kae7PQ{=>0pisi@Lybal5_9cpTyJRsj~EqexiiiXE!wF_T+1W|jpqiYV%%WBG(oD0~R`-;5?#46%;*tQ9>F(`q ztlfZ~Q-gh=((ZrGuPDI@!H5>jpn-n^lPw#KMezdBP>l7S8PI8R zkCB9A0UQ6Gwdi#y6x3&#R?ub{M=O8+YxZ=WC9j~M)23UdF7R@i5`sH@ayG56y<|uM zMmPm2U3K-9=^!DE{m^gC((dR;VA8NQiA_sesIk$0+ohZ>_VoQVu-W2~`~$4fOp&Dm zMRJGW3?VPy^I5ZEaqr(_QszPSPYue9jEp2|lze^fFOSz8m`TdDsx4Iu6x8otdVP?P zSPt4I=;`Sh5Ph@XpOv`wvai5lt>>`yPs>p6^2-Lv&=ZT<3Yy579LX>kjCljIMn(lJ zOfP;*%5L!Kb_&>4w`V;{0P{4#0x9Rk2&jGTWN0Y91W_5wt6Phu(dcSpXpdnTFBs&u zt??Z)6YtHa=l;V#ey zZ_Cq{mhUVDcme_gBZ+wx>ig?3*EcqvA|uDM=`SWd!;Ep+pR1mE{O#y;RD@}I4c29F z2rM)WB^R+`R8AK8PUc1JqkzD9L(@WWFN;liR?No&{+%nV2E1FvZ~J#0Dpn(NM2&ph z-Rm^{X$wf|Uam1R?&ZVajFOU)vow3aO2*paZ@D9P*RE{ zthGkt&Ba+pO-@L)dQDHSda-eD2g}y&9hy)nX3TfNQM5+DO@~JUH43OP6>Cyi?d-XS*g9n89fY=j|>PIe)&A{=}KG zohvg$;>m8QKky_!~C6`3=-bm1>ss+~(nX?=9JxvGSG8Hrvpws2t858=jlC17d zLBGf`A{$vLC}28f@HyqXbG)g^sSNa!JoDK~cPk5*DY|_FOTEk{*!VCy2MV*9EW{4~ zc8-kxAgJLKZvUs>uCfYN&80gGyB$qU^9jV-x>1&5=`|fN*(qI5xtt`lpIVu6&46Z! z$Tb+$IB`b;_%tgZA^k*8^9SAtOED#spgI4JoF}^c+-aEq{ukV^9Q}{DhvbPxj&@2& z$$*_VTF&?Hl9^b{*tM^kN#a}{l@h?0AtW@=c5%>WkAzOfuL(I5%gPl`paa|juD{He zTAIH{X$%pgD)&1)SEgBR;@60f21UiGho>?G`FAyOUkNVH+qJ5@_EgJ1vB5%!FC*Ki z&yJ&8#wL-_lYRqqxw0gaz{qeBAIGBZKb7P3>~(ffp=U6qFa1sU)z#GixurIYBALJd z`%a=U=qHPc#~Vs=o%HWCgyiIG0!ca2pU5jXBhNH}pi_+y93~k9wWqvAt{H$|)-kHX zG_gP8Jq>F;VqQsRi1_Xh!S8XBJCQH|f6DLKYh%L!rf}UWbL4u3pW}R>?lQAuY6LwY zUV5V3SD=^)*K0YZ33I@o1v}$|9gKQLDQwj3++uL2^L7pT7ZW@xQg~nIEK%7t6w4(2 z=%I!_lk)F2Cay{w{Km%QCayaPGSO9holZM;g#H2Q9L0)hxF`Cf99!<=$7p|F^nFjP z#-7oh+nik!2_2s-QZ*@f?BmTGMa(-ALbQ=CV-gGN5a;lql7kx}m^i>hu2_bDcQeJ1 zTua>1o`Cf(oGGJZu^!ZxR6c6C*2fZ@2(xDmBFpiz790dm$za}Fqg{3sqf~Uno z-pV=G0rA|QjMcM(w!e7SBJ>)YfWVyWQ6bm1N!S<}U*tYL*al;RkfZ}0Z1S-9-Nj~s z`{Dln&mz;+R?|y%!&)8L4pvG!nmlm@;!?l4z&7=0+x;t{U8j7`q-Easw;pts@k|N8 z!qZyTRCacDRzn)E9s--ok3NV+k{xZajFmSUy0^rdY-DI?N9#$mhp2=zxq5P&ooYt7 zZbgmV5}Cku%i&k!kk6`YQ7B;rJf(5!wX>F9JR|B4TY?Oaj^cNs;m|3>GmXu@O(HEN zg@4oE#pV)dCh1rNgj={2R3==8o#}n@;y%>&D-QQSwhT=)g;4lfZ&WHw zjkce0(^WoG7?J(tZE0tYO>t@8n+hV5j=%AlUUBS?Q`JGp3uwhNwhY47yYu;CL?Sbo zl9n}z8ahKx_v4xs`5sr_F&d*+9HKsAASQ*JpyXD#P$!yfS5wUR8E;*On-(l23t##p zApY1@YLUfo;pS}5?QBsbP!Bk9R53D`l057n7->2m2GHZvhE-o&SpQIc@g3DDJb=`EH%=?~sRzfzjQ1x;fmr^BJ$W zh#WYz@;aYMgfP&V3??S@jMcpd%=4|k^rVW1B&}@c!5eFJ-!*ND5A|Kt93D8`Z58b_ zXjI>wH-A3Gra`Gg9mVzg#-`7@DK?tMH$e?{pg87gQ2zb_u$2=Z(Az8w%%Trg39q)* zWzK|?jxkS&<=$yu!6VuW>d^ZvTV_KUKl_FxaTKKxTelF!!N};CsGBI=wQ{6Fyz)K0 z9;eG6mu55Kj15@}#lFDA#EX!+5#N7v^y6z#dH>79=L}~ZXl(DKKpUsIWo7IZcz@3- zquthXd91ZqwS>{bKR$FS3ZG(qlYVu~4^P}SS6bDh+h!&c7Ymk_cIgnz2=A}G*vhCR z(qL@YW%%tY2Vz7BlnUS|4S8~_Qqb-l0Y~M2i_4lbXSw=Sgx%w7ZzBqgE8k#*EZ+SD zppyjQyzM~CfR>m8f$f^FDzAnmXw=xR;@iJ$PJ5p!%iAa)Ft9{@pGtz9p@7GXKYmoQ)vdG7cK@t zrv_bM6(_3HO#cP&|L-`zh`LonK{@_x{y8x-Fy0!^yziFXYFEfWR#a&d>2{YL0DbUS zb!J+8ywb$`V`&EC872S#ODf=6msFxX5|QIP|6DN;Se<}QIkDcb)9(iFs|ud`!1IZD zY9^D}P1UZ4Pc4W96+lunt8tSh+Pn-5(#W(8o@cvF9;eIoM$Mvf#B)@KAEypKL%9Iq zFHlLHzIgHC4Oki!n^5sJHZE?y#)fHCQms(Qa46|*@lg)B&+nM$IFDaWo?xh2tTJ7%;+qpQ0!d(?=E9L3AYO8AF zTxDhDfpc1K0S3Y!80V761o$f5I6UI;k#vE*P4vB&czF4tW<|sbI?Q30Isnp~69<%! zCIHiG)@m%u%6dOdi%G!#2+iJpBfs8h%Ls!xWyZXz7T7dx)cmQWA`2raSFOWf_PBioK#n%> z3JyvI(krhhhFjlsF# znph9hzT)|;Dw{4jCMCG{+Kp}ghyH~sI^z~#-%Mu$X`EcIs`#;&udZPIoa)dGhr?J@vY?e$1WF#?lu_`-sVIVPvk*qSJ;2^}B6Za{hTF{nBp zwdOrxrV4{$_mH~?$2hgz_R5giEMp|L)it3P4Ahe=_m^xooP885uo&} znYie_B(b*PUfV4K@a50imoWl<_g7mO#60wCYb_UxUTynyz~MU2vZ6Lt-llJpvAxtL zDd2NmRY$+UU@`jmt|k=$w@V4B)$R5 zs1I+7n{8K!7M1zrz?ClK@R#A0kIn?PCGpYtIj~hb)~2F=FlsU!DdV2_&8~C0}w0kj^(hM*WMcU zJbJU8D_k4$;Jh7JVPPSSY$83vNJ?>D z4pXLZey(_qI#lDF+8J#L0B0fg`#x@Yc>-!KKsdkdT!}t41#*46mgbN#13u>Se!9%M zFxd8Mrr!3UH6?tcjx29r9!wJ&ovH2Z?da&21gc;ON6!LB_Aj|0k7Pg)(11l)#Xhh#*12>*C{1OJPtm@1?l5zVr z^Ty(>%kJa>qH(a~9dgZ^vbWfuFoD^1zVK zTj6Kg+SOmaA?;>;9g9m6_GuuHR5N;^y^`|~CTg)ROn_vo`0=9!Hdq$`nb~3WyY{Ok z#0V&h_Q%orfBB+j z*ECa{c6qVd9cBbwE`6=g>FuvljeT0uHss@0l?PSmse6dKd1Uj1g6{0smF87#ieh44 z#Em6@QwsO632k)hlT%i_JNF`FWcx78ZQa4BPe*RTB@!lfe0<#OdG-n?C7LCTF)h~B zH134X-nMHw!*v7#un2MlrOy|M6{gndd1VCn`L$jaiUpvS$&X`C9#~xbW3Tbj?VLE& zD8@>-32ey5gm-;y+C=OIkF-9Iub}9Ni;0z404F}CkeT}bd%=>)>Yknr=l)2b%iYvt zzN1EG$;rNxmN?jJ6O(L_dw_TgGgB@;Z^Ee=oonfd2ph_P-ZknE&(hhjIVs zv41|#`M*5&|2@qApVleTs1Ljm_^Y6!eilE2S z%Y>g#C+=M~hX^|ofNjMlg!HRdP}&Gg{a9dXuT<5e(qRaN(14SZ7T$wa>KFMqgYEPF zXEGX{t^AQU|MkRMX1URaVMM5{WMr0NX~OUu0d>4;bi<-VY050sxoVtfZT9uii6AmT zBvd-s9?(F)G6`t_3b_k{&DWeE$scRnrY6la$|bAZTwu}eHl+=GYwfHj-`t8iL^Sev zO4|0%+S-;zPrWlSHGNeI5Zu>D;FXdNGxgHPIzlTI?6Z%fD%q{3ipRGyrHF4AveWiA zH|xt+Q@rPc-{$vr+$AxThb>=WqXN^^jFgzES z4j=Mf9xqJp876xDpL?$|+=IzbTtro`3afsLV6ko^<4nDL>VT0!C8)%akMFJKI|oWs zFG^nKOp!mZ?U%vr`=36|6LDRNAYyD9@dHTVupLYdvZVGg`Foj*I@-a489$Q>*gY4P zxtSl5cd;WJFJHXgBOV5XIGP86o@rMw)|P5duU(%xXp#vxVQ&XKRWh3_eAbQGz;p~f zUQ2yE+J+Kd$1~6Ga-g6{9vtxXtKOS8pXZDjzJ1R*(650(-)lf1;>=xLTzb{T0(hIl zH`4!E?=$7dT{r0>K-L%~p1^j!v45vfp-* zp23k8*ZIt&%)OOoL@&EU?l03`-S)JO3zc4-yhY;N3iM7$tqQOptti#0cbqC#2eOGq zw?kovRpbQ_kM@HJ^_op_U&tJwATO9UjlcWDq;W6oVk!F*8#B4Yc!w1Y3rmwx{~og} z8!#?SOjO3tt;=8Aa5?aiGG;v2`&2gta2mFL?5XZPC}?xA7jMXUa9~|BnL!TmdAoxM zBXFLv=j%$1UW0SUa3Wyinbgp8<-(FTGP?LuZh5*D!oPnu$I({LG}VvRJB_(Mrifus zVg!9LblR-)PYpJ?g!RcYGm+!A-no7&D;w`AFx$Iry)vyj0&jF_HU6{B@~51L3dcFK zDHge~OBgYa^&_yx554sG_$s@lmS4ZD|8CPK7FJwqtDO5nXf?S|FcFxr!HzF&2i8IIB_0E zOst$!=;d|cCk(2g0a91M%A`R{HFJx-u@2_iK+FYVVRdjUt|`FSVFKP~GPsDz{%c{j z)u@|efbH#UXkIaKyP8{`=ubExoyc@?aeqAKZ38Brwj$NegT^1;sXPfL8FN_$1ET}P zQg$xJ@!-a6o9-tgQpmBL`Z~c5;1c~M99}UGiN+OE-6m1fit$AM%S2Us`+jw3!@p~^ z6a;*HWRx5$pT~xX$H$XNeL+dPT+M|lX}6T?xeOy^i4hH+bqp@pFzD{)nUo~Lj^wvm zazV*n9EAo*|IvPZ&i#Mdgu$;NE!!1#92$*?N3fXvb42~4JlR#Q2kj@Yjy z0puprw!Edqui_f8r68M)yxT4!iRBJn-0h*SbQPZ;INgDl z1imA8)(;<)@%rCAWZ^kREZSMY+OeD3D>IGa5l(5JXE9TdSLM%?|%46*_G4&%TMia!(DD zG#Z*Qw>_?STHMrBzIv7Sn|U8a=-;(%iqF?rsPgfgzgdw^MSl$C&k_B*YbOV%mD#Xo z<2AM<|E|nw7R0QMc%F}9DDT24l|fm`cwQ?BcUVg?dPBALV#8S3=BKL3YZd>0gehFb zGJ<@h*HBf-A-?vq%yIS~VFJUJcmCetB{x_*i2ld0B4eihYv7A*2=iWerjiPv=&bl#>msN@Ma ztO2Ox!9Eq6JGMguaHHPbolW>G|Gc|cS{v5TK=}G!x`+c0Ff36pEqwR6$+!fLK{`42 zI=dx)-w<^jM6NW;>MGd-LlepfsW62=n$)UXZZ91MW7T=T6yX@kJ^+eTNFF!+>+hmh zoZv8n-b$aF^SUEcT*lY0Uq{2_08*p|CBU4EzLkdQ9&dP+NwU@75NF2%mIMdW52#gNNYcl1ckDXeoG94z;@vXL;Qc+R)si2Vd zE+#c~??r{>Rv4o`L{0j@>HZd&8vX*65t~HEakTs1Gqz;JYbvC7^u#LVgv_YmJo1rXT4Vo17d%wjDvt+aw3lwln)6v2d|dFd7`S znx7{m>}+0#-V(^=X6uDLDVglQ8z$*H@}l5LO&p9Gniq}&#LDblcuCl+;K;~DAk7mv zZ1N{7WHJ`8rDiHjjye8nnRf|Kzm+>^@liB)Dm(LO$&mwb>U#n#8wP5(xsqf6?CH~| zK>hrOZlf!}*a0~JK#tV|viw7Ol9(o_i|@PlMn07Xqu}?lKNf(0@q~T?#bAvd*M*O8 z&oi-V>|rwZtS{(Yw4Kd_kWyl69o~Ujj@>XGxdvMuTkR|JHQ&9S>z=FM+sZgz@M3k_ zKlkk@bk?-UsiK$8Hp_fA>rQFK!7l#yp(On9=J$U;hRX^1ftOV4$Xkbcf>B^#IGS z=crk`b^@(Ef|N}g86q0U6a|%+Y&RR)dtAPIL&Z{{9l5Ddc%{S_+PrYyn$Y*f+BLs% zvBGWA%Lpq{urf;9HHHn}m{S745hMD;K-eYIwq^FQ645i}G4AUbYas%ARZ@Lw`D8$ndCpILs2=6NU zMu*mfEn1|afn2YSkpK){3aG1DUD+z+NF$@>;OU%@IAU*Hi174E;B^vWAA&n*9t+U5 zU9I0gM8pcTS@@H__d>_QQk0iZ*t6j_Q(q$5f6K*H1(Z4;`UD)~Gr3aqlB;f z&w7E1K3>Qzk&5O3L~50uJ{ zBw4@-V)MLlmA10ACcI_-j(0#P+1L9<7mlJ%-4|+`a{Q^p`caPF1A#ht$s`rtjhSN{ z);a)s!vhe{ZNv(SioG2Cu|}%z-?y>2(e(~l_yL_7GlW9&|LU~bVuBX-L$CQ7Ag%xs zWh|6cw_#nc!gW_ozDHjC`f{yczN*Mt4qjI$)rCxxe010qGF)Efb&-WhT0CbI;ux(u zZ8iBnN=MW!HI%n(I2kpLjWxdqk!bujw&yFhe_xDN{)Uh-c_3V#gqMv>;spb#7|eZ z`zWRtv|vNZE{>OZC;J2O48{v-352MsRAhANB7S=FIU7j2oPiy6d3$ph+InBx|1oTH zE=>g#9ttL)>dcDr^%%bzRPTRz{f%|Yd7g#8z?^P7FEAj$&;&60YO_ZB6*243urM4S zxhV!h5@okI6`Q%L_I#j|1Sl{R()!Jf~C*#(^ym~mEmprn5_R+@EFqthhc z3wo0pETmN%AgS8KOuZ2|{5ftji;;kzeZuPeV-V+qKcg6Dl9JWM0V2##d4JL8LRULM zFTCM-OO9;e!ed?8Ti2naRI1Qz-8ma}6@9I!|>YtP6Wh6n2WUrpzk4yOdj1Lup& zaZ!kf58`J%`;O$5jq)Mgj-J!30R9`5$ z?eh>Q59T==GT&NcFE1P1sz-_t|J8;Ok&l7Fa)rsDPV$GtXAqaSa{T?f!?~fq4>oFT z<5i=-@&nBxEqrX?3W)=XmS%%MSP$P`jkC6^IJdv_IRSdpROVI zNn?Z*Gtj^WoO8XH=>l$eNUBF=!w+keQHO%MGrmhjxtb6)lSfZ=v+sZ+Z-&=V!DBo? zOHkgm3bGbn)^n;yL0Exe!e?9)Y1y?)zQiT3_=3T`jz6a?#IxrgG-*DTBRQkrbKVZB0IQ+n;q8jOJis9aL_c1OUCbSLu$&IhK}|#c7y3 zu2jo>^#F*VhH4$Dc6Y`CT=peDxSQ+R>#!Lp;!i0=?LQzfsPx&dSyk@s;#-se#po%P z5B`+kDO6NcM(rBrIe$lA-DMV!%GcK$1%oVM42*=gr)WzmGLd)BhuBB4gzL6Y$;%&g67Xzqz+AcP88aDfzVi#duMwq z(S+%>+BH17NAiwc;hq;Kgp0Gm-lxN>` zs*4*B_)@VPOTE|@aMu@JRE!fT9V8q^`qjw*dHi!7zdd*fooX$Y&%FHi!Uw985t27^D;AQO=8z4zvyM5 zEHpM;Y-$Y%NXX!;#7+Fhx*vXq8H2m!U-5(jV2|8r&g*Qgoe=N#dmPrv9Y@k!FGEh| zuZxfTX#Lan7KPoO^0|G1RiOkJaowtXft8n+e`D1h7##FSwz)2+=Crb!za9oix+4OYr~Enk?=V)RC2{yx;#xP^3l4U(K` z1QLCJSIijo>u((VsZQm+F2<>QJr=wz3`Y{(kJ@;b!fOPGtVz}v>V>Y-5-M6GbUm+k z3Zh5_0{Kx|uHl0psiBIoQ;=Xs{C-C9;ZHQ}?KCca5TzqruMXDCkB<5A6maA zIsiICe)(!ep`7Hk+7mw$0yVl#6egs@ci3JFAP@?Z3sx>M;bI8I)|?sg0fwVOg-U<| z{4>(CoJbRZj83`wJ5^@%6d?OkNPth!JZh|b+Kf(k`|dsD?m7b-`(idi2y>d}AM2#zMUea+^chtFOLQ?j}z$vod1&YyNI0$ zJ7LbAke!UmD~hd#iw<4U5ou8I&;IKf<0zmfw;(qcVrpt?W)=sFPwltlJ*h#s&yo39 zATI!g^OWOc62W(B!UU=uzmbTDz21mS8q_=Y3e`=#oYpt3^x*Cj#N;9MO44~7MGfV& z*_m9J)i<#|s&w8xoBhitS+%V% z)2==#FyDt!xT2_;=EpOOM@FWX28M>5ubIY2u3!Uo+o0vdXpTNo{e-`r5mQaTkh6*R zYA&y6M}b~Yhll54rqJLZ;+yV+D%87&f<( z8-w*z0kZAXmoqwo7YZd)cRweL%gMA^^jzS##-`rhO%wVtfb%d57d$hPj#4@!H9$N< zXl1H2GEMoh=>1Z?>i9|sAcS65`A6;mlZIc5mo@()5X=4=wE4`QaD{aB&%=pg! z{Ws6RV+|LW!Lw>5d-=Q(u=eu}G?l%xA(A85(~`$=VVf}!7jf-|+2{u0=K8C)E+69X zGRE=t++mx*U$(rHmc#v45bk;vT*$m!m4@NPW7#@4jt>llo6dzAZE_h%I_33F^GJeE zL|pH*{2xTPw`tA0SXWZ!$zfFQ@Xoo>Va&zt=kUa3&iWTyb`KE>rnxf#QYs#vJ-czD zU%y_)jNMCjBmnL^WwXD=$yk2Ks%siCfUwx4Wms-|t`uXUS=1We^{ zuI87QV>v#gI+C1CY0Nj>x#AO24hw>@NC;G*AU?uAbUIox8sB@5-vFbfmWz!gF){H> z_A&FuyNTR0m+eM#KhjyL zkJSUbtU>gO%fulP&k0z9N{K;?iN>mT*^B6RPNsE; zzf!}CLAb9CB>YE@mM@YrcW4 z!GlOYo9yJXL_&5hK{)6 z)r}NZQ-M*yr&o`0p?BP_n=|<~0S*cBn@=?RVTA8A(cxiO!5mLh>5t^zve{J3h>GPFa{Qf7uvOBYfnNZTj7Y#xt*1j5)0!hzC~JS=Z!kDgCse=jL?|WKh8o-~ z53l;*W7kr2zJ;S(9uR=eTKRJ-J0z$;|;X;!FAjmK#k zhshT#5={mkZY#X|%~YJRhz5r>B^@0gE+VDb3O3k8L0AU*Z{GYwqe*I#>Tlp{`o;t| znBFw8aPiz0Q?|epOt+pTRE(}xV*Odq39LZ|jzN=hcl@M|5~0n2?xCP1C|O_OI6N?S zoC$bBbn1y-&LK*s`aN3RNQMx|RwYv5Q3DF(ZWoH!pXcpXoU8Gn=j9bYxSF&24pn;* zCnS+qAPba$;SsWMiZ&XnvW=Nc*ym#1@9jCWp&NEBv-9<*RG0W8G>Hkux{n`scXnC< zd~e670b@3`!l`d;stHi{7Pz%D{@Rm(o@lHpFt9$a;x zVVm;|$jD^^gl}~g_s<%udRqR5r2sU6@-mabg_#-A*x)ZV0&c7@&^}vnX4{$f-PzX9 z^YW~((ZC*N2sK~*Lht;7^O6a`&x{$GG_)fqLtO*DclRj1ewQmBJct_TtnEKHY|5sV zuvDxP`CVIshlm>3rhl$sbw_1Dp2fl1>%bsnUOVt)B;z3kX(>#Ii%d4)d< z4d;4sPinV+ghXyd5C=gbdNB*_=R!X|Bt~Q>;Pe_4l;zKR3>u(9`&!NIxwA1*e|{ur zMFf-I7lZ-oS3>0)pAu2_GEM-8t0cchO!%il1)pjE*NPaovdr0j!uttkKEzJVLlBFF zQRn>wy4fZ%TYIUS^M1#)zWwkNLB$%^=$;6oa zHd;fU9+S2kAgnPIW-gyZ41Xi^H!br&?vI8AD8j`@BbRcMi5HkEuuht8GJg6oyT3i2 z0l~mY4Y?jcQXR#&lGSD%Ga<=-8N{#@ssfUf_x{Slf1aZ=o4f}(|Lz*u4&D)t0h)^d za@*&6p^3E-Xk(~w9zowS}@R#dgG$|;VP%E!X&P<<6bdA z`s!rk>nN(<2!R-i>t^HOIoc{EZ+V4u915ptxSVwIxAbO2n;G}HgA1FIRSUW|C@t2b zVor!KcIwq(B`tiv$j3+CEyE{{M}A6p@2*+hu8d);&t{&7e9%#B0`TR0xsVSVAJV<9 zHd?>?S-yw&^<@+u%x%Hu=KSZFQ53_W3V=a5IXeEDBVxG4iHCa@#5?MYU{W@`@v;}$4c%IO6r{Xx~B*8 z3sPvnTjLCN*GcqWLv;?~Wo1U)uOXD6RhUA$49ZM&>rl30rt8dBSBS)h6KMYhu})U$ z)wn6s?aMT}kzPUYenvPPh^zre82Kzy4d$&46~}#>*~Xd0dOtqA4|$Z!SPjLD^x;@B zuh=D)yDT|}GUXVWFCAa61j(sgFE3O4rfhWeQ4>66Pa8P_V>oI{_IU8j_Qi4vC7=-%0h{ubu|FXGMS#jo;m#{^{1Jex3cwc%H0=$~`6G zy}>w95^P_~s;tP7ZVT2PnP6Ag(hfOlpwn{hjl?oGad*ey8{J-RpE}#qc*dP@h)Df>_x;J(S-VQXa-W~1;958Sv<@10{xhE^8pCxKS5uYrp+qu`7GL>}Dy zS1rG^?c{+rL~S}_17c~AU7Wf%@4Ip>4Z0TMG)-vZs1NUqAow9$%%r2E>-<`BA}aLf z_7o_pkOG8NJcDY!Vy3VaoVm>fcLToyw@J$cgM@}@Txt;C*7>%vy$q$u#mlpos7=`l zaV>i2?fgZb7f1r=n8q8Cq-;IiKZ%dA@Kh5u)h<2Hgz|6phmxC`n7n!Bj`Bjd5NJLI zYTTYc2k!lr$e)ni<6Y2{M9h()dkbWA9u^UJ4jy%635D7FWhIQ73K-PoxA1~Z>7+7DX%3gxqU1Us>Lppa? zL!DVq`<-uUr<}p`c9?g*%W6%kmb!;cnn2)fxuKnPwe{-{YsF4)4m+xRCWhjsrZ@I5 z{2dWQ23H$*Y~MEp%rF#6^y1a>PCp1)m0A4iV!`??U$mI;78pBr%2)feKt+833=qNc_e?Q+(b#_5riFq4KPW-WU)ODzU2)FWIpMH`>pyDFBy-)xA06>N=s5WN9=?p4#j z-BtH?bnw+p4C$ATU0wdJc05N7fL zUBld3ytkSw4{`S?4J_XmRHvWY$Q~J6wdKlFLxX(ae#6&{#{QXk6HaCfnU#sDdZm+- zDz)-07_Pa`|04YaO_sOS5=TSJ^Z5&bt;TMV>cVDcvQ}-7LBUv<_sH(9|JR}ys(Zsf z==gV>IJTl;5JhZiXd3bDH$Fd))1%Zh;-Rev1KrKI01u8bVhzT6@&nBnfW|m0IFUh1 zd>0Ba6)18KPc+-Hlm6L#2aqV(6&XW9d>6|Ll}l+XU*MlYfRqERU^g13rc8jrn(}bN zMEjwD9{P~`KfI^#&{jwbT&h26GoI@w0B=mO>|-OTJ@=} z0CfV8U$z)(kchCbj*f6bszx_E>A$m(zqnH!i8qNL&oS`);K<06E`LA?^l@i@Dn;8VRG% zdLqOf2^SfcpPe+H!Kp>znZBM&LsW!(L<_mn{%3pov(;8(z!|C(qyAS6MPjum4UoHy zZ=Ir|pQ#CfCG&4qhQ4SPxs9GJU+Z+F&2OGUyXc!Yd0V4?qQ4ee zd{p1Ua{msjmaUDnk4`)D8D2tudot)88~%_3S3dSHa1;MC312YqW%vTgyUl>L*gixI zv}zh(|G%BCv?)@t2{wOKuZ8qrpMP7E{!gQwvXlMS)A`S?t^Yptz(n!><+J*u9&{QD zaJX`;z-UuNdb_QrJ_+URqvJdEl@EQOK+lGOBjUjwD>@#0P135Z>#xrNrtp^l1HS#h ztpO1C-;nh0#ai~^kFkfa*dhs6{n5CEQ8Sv@ykdmO7czJ$419)52}YP;z%qsPuV|Od zXuv`Ymt`A(^O>p1>a6iTBJpGnNUzfvYl>6RyC13lU01|6+_XVfYYGmG< zHQsnj-&2qa$-$AG2@$tPr;R*sym`&>k96MfguP$op*NC1f2FM1|gRvD=^EnS_&p}cp-U$fUcp@}~@DirWWTZg74YsGt7V+I+ilgubMFI@r>(0TwUaxI=l#df&@#CP z4PMoJjeU4CDJJ_*tbxM+!`4@aMY%=&njozpD2O7`AT81%h?IcTz|f6!2}r9*iAXns zNC`944BaUW($WpW&@jYMcMqO(zWY7*{^f)3yzkz7?X_3_*4p=4Uyk4WwWe%xP{t=0 zFDTx?Vrz3ccz%Pue{y|`-)ItZ?as%}hKtoyR8;(3O(WIe$EPGzbv63uAV$i`u{(jt z!;7h6xNVdG!k4gJTfF-ew<$b00M)9x0(rTmPnvo(w; zC@g|+({w*;T<2uvdJZXxZ>>|3bA_I`o00RVt1lHk$97@-YY8mgeD!;NYRx52|2n7c zm^~>ux00%Ax+s4sgf@(wAOC88zfh=*VzpR)^TswF@-}bq9c~+J=ixnfjlXaDY>^Q{Z>1yLv@*``e@Q z&H~@=^Mt?SPNGUZ+D!Z2g~K;g8qle~(8v3JQ7LUFfQ7OkEADg$R4~4K0G(8+qEuHW zaa9uGA*I<*vhJ}Z*SqNZZS$jZNTgQY#f0=a&Ua7?zla~>Mh;clt~4z#d1H3udlyrY zmTn$EWLQ5X>{vXej}{mXX+ z91KAU??L(lPn$RM#04iMe4Pz=iXfFIL+6Acg(~-GCz10t4al@lo*T)x*qx998CUVh zmk4k9<1m`CBwy!AXPT_^!X5*ncHDODh|f}A;AR`9dsf+!8nB1uk(sOjQ@V!OQ>*!J zj*kN$kw*+zwBa-HtmM0j>mPOy_&c3sW{z#X1_hXA`e%1Ydn_5sXj1mWM*ruxPCB7e zQ!LSd*O_ANTJGpvd{#$R;7)Ok`vNW-Ka%>B`g5gJT2wpKx<;^wxdv+5lQmfkk^|Be z%;ncQ9Qf5$!h6HflXfOGM{B!#6KH`)C|m$VE-Hak`{0A~Pm6U^SV z?k6Vp30{;yXh*qg1gtL;{T2p_i--519Z z{;EO(Z~FDteJb`v+3>+Kl>Tf5Y2J;G#Llz#u`F|Q!gEjUu&o{@a_ln1YrwW-4xVB; z%*nKydaJj9M~oSMWiE~wJa)>o)(=_=c?7k!0#x?j3D@|886}{;2}*7|1T%-+MK(>J zVktlY^=+%w{bTQER!q~*bTR!j#j-MH?0LUD5$2^R`WR5i$g5|hw#vaRF44}cOK=E#VL3Cb`}N*RvzIQiVgtdR>>2u zho3KwV7EomRP?OBKWv^tC$D;6{Ar3BDfabrqL#$Mn?M9apl4>{#5}D-hC0|R2JC!R z!m;nqWfRE(?ixxAV{VQk2D{UhD+Q+WzE;?WBd=%v?{`aP_84+VQj-o=I@zEu*suOt zyq`e1E`tE9Nf5#3b{-geI%Pi9ZgcU~YHXf9t{Mi~A5=Lt0zKeA9ZL(%lOFEX%+NkM zjnyL&Ysa#9^nrCWKcDeOg-u2;9@~WL%Z94biEzk`2Nr~9Qn%Ego&ZFCY>NI+HU?^; zsj;<8AQGjN%0+W80Vw^P56HuVJUjHD3oc7K|Q)B zp@CrFPUEv{KkX*URf0@g@Mat?U%m{GEx5Y8yu6N(Ts59ypbG2de*tVRgcg->9WPL( z+V~V93>Y&2l(#2LNQ0^&9^Q@kGVcdgA^2cQZ0ewG6E(MxkVjJh(Ps57@dRY|YU9Zz zGOQ%PbR<{W6Z=-CH%JWAM#i-3nM#kj=F-eyJUURUl7&K_V1bfP7^ZO<$rsBSs*Ig7SL4&}xA}-1* z#K2VJR1Y>$w-tnea0|i;H%v#AZ~2H<5h$qwagN2pLel&mkd1ay0s)kyOPbp;naW~E z^dchE)`j{NI?tXx1GSTdXDxRO-n@~BFQ|^fh67^i?!w7EF8%$8Fto%0cuvlDp#DvX z2}gDBiw>rJ4GJM87N(~3!)~jC?R}SLqmaW+q&V$XvQtSJ>@ zAVkIS1*daTKK602uPp`TE!kq~F_44IU&{bNo+;OH+b!MDc6=)krZVt3GLw+F^t3xw z+yG+28>Y>Rd$N0y_+;#I+-d!|xj)ZtLEps8sn0@#Qdc=#}>#z;Ug&fbBMtjY8`dk z(__N}!brn_I^6E&;YoRwmn>EjqjLlFo0$PnGJBdvJOl`lxgKuKf;{s3WmXFUe!xCZ z01!Pn+3j@@D#AMlRGR`7#)Me_N_;8m@E|=*tP$ag6ZH#Jw-)AD4rCFFS}o!Nx6L|& z+`DGtlm-K#)qgqF8I+gu5QRaDcE;A!n6}S>nzyJkvuvfI!Yrx4+)BGlGSfUw&fEF9 zR*L{;^lKv_j^DK{90%;rM%{S|8PoS@$`>-?uhL&Y9d0u*3Wkch;HKKHbE@TOFFH9H z7e4#~fbBQAO+!-!o=|zM4i30^{aa!p{A`$<4FbAqu|9shHfoU?lez^kZuzcl zZlH$TwH<#GQl2Mo6uO#sk9K=Q@h>8;tIiWy)ZBC-0B8}6fv#H>5gJ^4na2FJzgJxm zjY%h<@Zh~hyk(Ko;ns^wgcP7QPRwf1FJHtWM!2mj2Jh8ie)BDV&g1#8-tL3}rG#M9 z-3C9Y29m0?guGMfPr;UiSB(CHFYPWp`xm~{;(ukL&es9p!5Il|W&$iQ|Kp5IQ4ZOh z4jL%C&aeiT01qN<0*f&pDF{Ql+<-q1o;5A}2{v1|V&`Z%wLr7rfr#cJlUE#wWrDEG zPOQ~PevM(nhx3G|<5;9%+@qO!$d6agyjYAu1?w26w5&#+HaRr(f#}%btqIE z*`IDg3G&aQwJKvcW9PTyVtG1>#@ku9>G=HvaDuPj-Nq;X&>c>`5y!poraf2q6T!6) zM5LiSO}rKXP2yKSZ^IyYtSlrMp0E5)rRD#3$=uH#El?`GAIt82{}F7DWfqh4xSm5V z7vv#Hv@|f=9VZbn%-2qc@bIFA+ami!pC9eG=~g|(A(-~!9joXw9Q+YT<8#-vyQ70g z@Y#sor{LfQLAUj>q2YH9pix@G3btB${ddtkP#~MY{-?S)#x*|sjippI-bK{2g^jk0 z?+QKJt=8P+O{oqOxelB{+5PmhdnR-H49hc!s7_M3p$&Kmv`+EDWuiU|KGq@d9cG;4 zPSN*|sA1TmyPpg*oLr1f4#swcYofo|CF->Z_ve1ed=W8o;8Y)7RoagYeC2A8{E#iV z1$2fuTetTO`SOKgzGw>GH8+{`4_LACYU4slvMW1A!0x|jqBX==#zu_uv9yMzAJhb7UF3-T`MlS(p*Qz z^mJr{l5F7Z>R@c+b|Y~Iaty$jDa6i2JOI9kbw4Pu8Q_vZf06>%gE+jmNxa$jMPwg0 zS+v+0kJ2KR7DLG=307pr*d<2k+>h5ejEpi%juh$%won`2x^MQt-Y(^?MZ`0qfHpJm zT<3cm+ADSp|DgaD1q$378X5%F0ge(yd}DcG0rX0w_63#ouAo^J5a0+3Xm5d>Ffb}; z*gFQCN05T@h@^Oc?HjjE*<{JR;_xKH`6oM z$ODLp0&(%e_M1rP7I(**o2TBrGBmt20~8L|qOW z&y3xdx*)%4zJaXV{7=3X$rnG#I+nlwyne%1h#|8;#CcTwrb&e?Gu!q<3Ss6{WC!&d z5?UIS`Dm{%@K*o_=r9J_D)RA49RmMDEU_^B#XHjye5km$d*~fEr2c2k+|OXcie$9&S4z6e%8l(J0V=Em3d#Tjn7|WG;pzrRA}KjdX!DNL~r1 zz(qgCbA1;6P&FT7O1?SuF)%Rc=q}mkyEF21KSzf4*_pQYgMk5A$!sdJoBpRo-P!r2jAr9;JXt2QZ6OlI zwXs=K(ANa$`rJ2KT3UaBr+CRfKjM+SInagVby`aq>$4J?nVSAO42N5tR<-aN(1>hQ zWsX*;Wj}C(R8>t(8t;gLU?66Hw-R{9lt&=t=TZRIzj`&@{5L5IMD1m5DQkBw>P=r;q z{kC@YTkjCKKiX2NZw~Vp1NrE~^6H|!genCC%B{7xVSLw*=alxCr}BpMi9#SfEaZ+V zvu5_^CJ_HUlKl}bX3MOj>*eJoFE0<8kj2$qmV-va4a~d>f&X>RJiKQf@%J^($RWGO zK;-2s_39ZgOF=5xzkH;F+P@G)C-rLi?hnVhRfRG@6(fg+TMzEAuWD5|1@VZ%?BbOt z-+k){O3Sq;ob){x{Y`n6a44TTc`5Tz8J~3}E&wAAvy#DZ|f* zfR*{w{4mRy4s%lp`K3080ShADGjKVI_p=Z2b&0v&*=*9@R5PfSD;mt zd!0+KKwajMR!v^3IM*~@5Ndee;TQFONI^>KvEm~<1$oyGN`4(C%sMOQ9B^7&I=%`Y zn;3P*)(XU~et#2YCov3Q6Q3yuo*&8SuirAcKnA}{Wac(OScMGEyb!nrhVK59=xC=x z3K$Ta4zbfd!#v`>uQLM%R>fDKCEHM!e}{H?eRml0PIa4mmd4jhiB#pFTq{R3zev-{ zn(f4i@<;1TP;hWfl?#maLk0eid(^Tz894ln!H4Das9L3>I5$%eLk4o&NC1&SmF0*2*8z%e`Ie?;*plUYg%2@jN z2VDb!8kY?UvF{DH8tUrnFFmATf{!PF9*4ZW(C*e&2IxSNG(|_?0tjns7z94b@~M3^ zWG}xD9DByep$!m3poh65SexFVv|T5;QY-x`OWQENqz(W`*LZhHY`I7J-pl?z9A@%a zV}}5zD|s7^z<8USF$JW&RDni;2CWko)R^0N`L;G|QkO~{w=0z(QL}n^FaL#_rYIzJ zmehqu&*o2DU{2)`l^KAWYN9KrQ}OxPtX@PA4g3M-B{gL|;ajes@BT7X=?}`=>T?6i zsh1zdC|j-LFt?=4xi2Hk^c*9>rpn)v5f%0Rwl4_V=nJKFJ3l>WJ9I4l4y(mj-Q!ZG zz1NdVy05@59Bdvz;V3OeMbiv6oy&} zJel=4CWL?f(me#eb?35(!)t&OU)e~%_f5Skmxz$?PhWZ#AbzqDs9Gr`O$hpv+Y!|# zTFDLNb$xF@4|QKL$_h7XN#)cX1*3`J%t%ICJPNqG;RtW+bGbdjs@eh;4ksX3GZ zfAL`zvR+VE$7k}F2=skE7DyX25F>gIJ5M6h$v(pwaKw+GM<5#V+g_jH5i~Es=j`Z6 zE=s$(MN|@jp@F*9ox30IZPSLl@vd4e; zAYTkBSH)-;pr^rbyr|`jjEwlBHZj2D6tsvlA8QI95TBuEGnwQ%x(4+OG&@@bnDPAqtN)fPd4Aw1@shFWa1oK3mo0%S9S5&FK}P3VP<&9J!R zG|pA{Sp+fT`d=hw*hPXiPYHtRg%tWYbqZh7e+c7ob-P8PB!fsS!>gk4rO>+0vu>jC zFG3TW1R~%0=XYfTYPL1!@?qh24*ZPDQRuE!X*`Xp_#FtjG^vsiDJSvi{wcuhQdnDHqr> z3>TE0r)f&FZFNrg5{|)wL3yE+tgIpEHo?TWKUIPB1x3`+#L0H`4z@u~8f$h{xB6WC zlE-&gN;>D35AWD*(1oct3wCa;k%+$G9QN~#h#-nFm?JmXZFh+Q#y!;DFI8tLi1GQK_Xq0mo39>yGcKw7 z*+V^Eyxvr@0m~b+m5FNQ%*=DtWIINW&|&vu*R-UE(9?hx7P5KiZa5!fmZQ(vTP{#m zSO|(Ya<15+a0tuDzfTA#mGeBRv?E@cpNL^mvzlvOY7}irm^Uc$0si1lQf2-4ue8~Y zX;8*ax$Kw947Edr)bdEK0!PWuPhrfZ+jdO0+tm1F#DU^8urkBW9 zqKk~VCfteE(OyYOq}0WB3Fok&(sZ)K9jg8H0g#6M9ns=UAmv9x2fJ?ZI|}}@v+{D& zdH{3Ty<2+xxPd*d1mN!$lLgmJO(Czk`&#RrL_O}xD|>HD>|?uxusY?j*A4$OCP;dP zHLyxomZ{#Ax9^{0>4=H3h75nfu@@}8UO^d!h84JiR&&Ls?WDHjV$n%SvrP-oy}v4DHN?s!H8Oj0uu1dTzgIl$=OUXg8;xXkHVNqTT{kwFYFZ+P zML#OmzTKYFJ{W``6o!qOJPNe9uM@x^irCcif!C~m5Xi(|fnUkr12matgz^1H6Bnsy zHWRn;(4hPzzh~D*|2OiOH~g`Fe8ltTSAK(@Iak?e-hR7MZJS4uhth#fRf6s-p3&T%j+mo|%``-de%>yQYINkmvE%ooE8Tb0(oCRz-l2ejsQhpSj!-Xj6Q4aq($jmu ztIC{~ecQIG7aGInGN}eE8MG}z+bW4`jUSw!9Un{xv(y0)g?+7{O1?q0 zK)&OAW&X=;CTPMZMyNPww`a2cU@X5hU-5N+Rt^UJJ`ls=IbInm#DlPq?+{rBaJvG- zml=5v!M31&TA=?B9v2t$UsC5Tu-{!0{1f^>pitGuYWOkrf|Lvvz2N8wN@a;0HZ!~) z55l2xBH0bopU5{IA6W$YNFsQSQ2g;vHHO_KKN27ODpJ-c(4_nJ{E}+SmPaNG^yCigziN4M>Ci*>#ox>M>Hs@eJWB0jS~#Pg<`D6vi;>TUDifc9A-|1U)Ez z@Lqn?J9Q|Aa(`@ef6m~okD)1m%G4`fTNOKTNcUt~G?^XDSzuJ83W~5;uoqz|vL2C{ z9k~MIQC=MW^{Z;31D|{&R%h~z957)!=r-Hi3!6mj9_RgStvERER&Iq4$xQhl4}7lbW5~&tgmN zt2l42^~JEYymLtqb{KTT!jUtzwtNBViZV))1C zBQ~-qvUWl!sJ#6e6*d0^DlJsvp`seo1Ju{Mv{%wAzn{3y#o}VJ)DKjniT_Rl`Q1EO zCwf5zQRx;?<7Br54p-4*rYE6Pe1;v-e>Q8!N#bixcBW@JE8SFUwRnBc2-4ErYWBxo zFNz6-s%1ycvbFvsRu%IiU(RZ->tg@kaMz7%(2^J3wsAlQ1_tc4 zpNzDzniCjIRP0Y0&6hr1s{u&8TTrLJSi5gWp6fc?SRMKR@xa%TB~k{Mc9i?1>SeVG z2TbSaKpGy<&}`=6TbN#zn8!*#$AMDE9cyA5{Xl>3<5-r1QG^+X06zci2@R#Y`37|! z5)zJ!UgiMgJO<>%@7oXbrIdhI2iHzFpXdSE5H(d*(C|uAHCpg=p62}RRs(Jz35^|f z?JpMuWFC7Zk>gniclz*F#ciT+T&M0Wwx?nSa z)42i+b!=RVz_etB@c8=+$RRbr?#q~)FWG(KNdrA3K`p=N)29?MOq`s((ukB4;d>fm z9e;6rSUb8~IH4x*g*|8Z&n-QCYSe}ubw0l41>XrYsk zs2kRls9Zk!1A+tP{1c%CU4;jFhzGTA%1_`sEsS>*#1cRwA%7(Z^C|L;dK;;l!rHm9 zcq&qr9<@1cwbvt@u&#*U5uEI?uu1GQ^trnI=p23(`MZZDB%EB^(Z>Hr0(u%=x0KFz zy^b89_WBrL~^U!7NeeT}_;JstGnR#r*?xL`Y zo_&&&PH+xGWs94qy(g(RB=>a$dgn`*Xr4^@v_;7Yp(l#zh`4@kG32-lMJB?2*NdGm z?>3;{Fg;r#6&K$njoaQ0Je+avVyEt9A{OM+1CTS27=}fmtLB%1WAsKCdXzjwwk~2P zHDO?Ju0dGPEh-SVo{S)dE>6tIJF~G1BAP>;{amEX!r{@G?f49_b}j%R(!R=OqPA)9 z_IyKgbJ!!_W?9`Rf{#vE$`C!BdOog_6z;)V+uoloWxij%*&U?F1Q|y|0fG_SaFO>j z6!l5qGXPAeAnag$_fh~`g-ja?oeepbI_EmHd+;1AO!ztX`Zy1R%o-eWK2ra=m&_hV zziaf98N05=|H2z0REHT*?`z3=3gqaZf~gt78Q{1%Rzs>IynA0F0T6 zzXF6Q1qlkNOMFgO^K`@y7volJ9HU*@F{uC@J~`E>H{h2i0V%o!$GLue>;p_DdlUIp z4OH7FJzT=URo=w0*Ko7r(xG0AGWG9Il3v<4|3zTRC)lK4QEfZ!^)~gt@*ICYkm;}* z-MI|6nJDKFDm3oppNfB%0%yfFB(IuWBc{=v{GoFHG3er9HBKwCUO99`zka+nQBkN}QfHW^S@5);ox*if|4Q>G(V`bG zZC?qd&rMglz>JrZ(ngdS0zXDWHnJw0U6nwKH63aoAiz$5lGDe^Q9~y-*B5sH7;=lz z#Q1@@<6PLZe3+WZOO2kp#nQtQ6Q3;yjByD6-1w3!7WMZF<}bTTtm_K2wmr-2>h7?a zGbwWH69QS(hu~jdRs2o?oj7`gAk=k!*+b#+~b|tS#zk?{RM37QFTB zdGW(rH$S{mdM80D`;hk8j56^H64DN7B=8}Jd^x-?k4m`*Uf@+RL9(H z`rt^^ov{ju3=Q;x0o7pSeOdvn?L1MWpLD*7x9 zR=UKDWT&L};-|fa&@x%ohiPb9gCBhT1sebQgZ$-y+k(}x&j>t$jB->K2Z!*j#JKq5 z7oUSy413`_xm#NvW7g6?nUqWoD))?ZCmd`)78Z^rrhpNHk~K9nLa7B+&Eu2(-1l)p zj4iq`a`}(OD0zS1Bc!z%{_wlqfJSIZ4O!`wtbsCn`ub*(li7El0F^4&yyqWx%Cp)d zBlRAsa~lT|_qBepE#ZT$Yw)u{x;_Qnu>W0n)}P7wny&Sy>(Ium@v*wZLN#bW2(m*> za6?ig1i}i_WE1sZwjEW%c#bfSfw`K8cUwZP-aB^~Swd(a~=b5Wm(Oxq7^%ld@($E>HS z-%`d4yG1Hbo5$4cO>tz8#rvg}y{(21f7D z+vC=K^A@-mH|orhlSZeI0xbDbr=p@+rpKicXA88UmGSrVIJkbEzRx`7X+w+{EM6no z%AFx4jN@V;CUeoK%6Y#tJbi9ddxkV3v0ucJiGs5c{%XKHbhXdKKK+IK@qR83EEnEd zRQY2pw`_F~o=2SGpWGOGKDoi#zIfElnU-JvrkT@%VZ00+JLU=Nl&1d zKaYgEK0&>WAx3V?ObWaE9%;RpVM#pF?kHCt%UQY-h@OSXKp|JAiHupxCu>QD z!~P5G7m1Kg**9+ZAkRb{7t?|ZJnbM~kREKyr&ZJy8KKe zck@jQTBlv`&_ZqgJM?NMmT%dJKl?5YBDz!JW0umBIrT6VHm!SAspI)~Co8+xr%qEt zsWiUa(*!yJxWF@E_b~@Y zEXh-bmT%)$NxLA`WMR%qKykD3=J#(Br?W%w1kyXpfBvCmT{kOrLgnH7ih@Y8tcpqm z+!i2z9|gF~aO|wKTQH|)c%@_ln5D(BeN&`!A)_GlbXG%u!(R@N=o7T2i(f{(+gfa} z$te$!a$n@_D~P~~y)BMggtGEKGeNrHW?3y&|M+7;{^auCJ?Dpo)9^MldEQXKReGEN(R32wG)*VMB0t3GBS`U|Y1my@VE*|zax3wqscp%?{UeR1bnPj+?>*%xoa zJ~NW!`JQQ%G0lC;Q_wf_d@2^)1lOP3RDa`Saa{BurS;l-Ye+$|G5*vla=49ZaZ)if zaD>3-tvLk4emc~%=%UB}Ic2pMxy{XHj%M6MCmtU4NYbN z&OLnHt*x=`tu*+~K16)Evk5-qLVRDdx#^bUzm3nfgJNx0Ar4KaxG&u~x5oOV2^KG*h z+nd`qt`+aH>fD~sg`!3@h1?*UsOI42fasWEVzjjTKYv>5=$BMJTq!6E7ZePRsK^&j}rbXB>vH4 zN=JFV!rAG(^$GFQcygpiAabQj{7-7VW)QT298v)J8AdW=<-PR8dhw_rqt`0nI3Y@) zYqi7<)w6F>*Ct+H76hH-B|1o~ho{h)GdSK9F(-cx15#;TsWEKlg=()`(55+dhB zv=h|b)zp9#x50e)(*T2@%2n20-xjxE=;RCGDhte>#A$KKxTx#un>>OC(bDg`EQi5n z_F9WYH$N*(&^Yv*lizR)f=a~>s-x#7gUS3(3c0~7U!5 zIwUu){%13DU!RZn^zL$uS?3`CdY{zS_YbQiZXhECx~%qxP7fQ2&Ijg6*2ox_Yd0D( zf$1^@dXl^@xgKFJhMCC{(Yi}1-s$F%0yoT&_`>61qDi$$n=#3_ms`Vd*BPEXGa2qx z-FfL`bOne5u?3V=D}wGAlFTs+V^}^jJvn?>F>(#4$ahd8frRKYanZASO@*G*V12nP zO-{E@jUcEx{p4Y{hu3TXo!Xr!0j$>7a&ghjcHGg4w2wu8w3-cWwpnQJk;(t@ zs#nQsO1H+1bn)_)KDdm5K0|2pv#lhJ!=*pjV9z)na4r!rF7th0R=aIYa!N{d%Rx8U z3G)O+aIqPCu&S-zAwjT;yxhZ-&G_V`&}tDhYvQZgkBqOM^2uazJe8BaNg4<}ZN?g6 zpObpsUiNf1AiEKj2aT$WxA6&R0-^b`{;D0bJX@b;Q* z&^q4KHrEiCk?%QGdiLJK9p<`8k85!Pf#iAyL`7;rue?>;)h`IXgD>{T@ut&1zie3< zuwv5CcH*&MGJ=3f`;Eb=9RSszRi?SS>kA08h)?^vG5v(66pDI$l-oEAfO1&DqaH8aopj%E+RsG(Tv^-vQ ztyAZ62QJ znvDA-{Y8#vFcQga-MwYIz3ko{7%5N>A7LIpc2pX?6r~#!099-`+P$pCs!=ua+y<^4 zXt&h$uukuL&Qrcyw{CUR6$t$9TT&s#jYl)}m~GsuNL2+U^llNf$r<6=l{mw~5xgzH=plJ2x zo**ZSin6@bzzb1f7GBp2wI8ee#&)gf{ZR#0oqj<=B}q@ zcbJ`svXWDQ?$!DRbno73=9G6_hn=LpzC^4JKiJ$qov@4uFvyo5xrh1-n7#%7HohB9 z`uh1BSMm%DYN%Um4wOF3{2ruEJj7(T>&Q|YLYQ<}>WZs&qiB>38WV3_6I51$9K&ZG zv9M&-j(o)9=fnM3w*!@b0LZ!;sq5xKWs%av6iHPPR;95Wp%6E5$PvlF|!6l&{-v_GQmsYov9Msfq3Lou$^^Yg#vgY&1 zuM!q__nO6l+!|P_hQEZCz6=Jo?%s{ID825_Ct3*tDtXUBp!c`H5nie|5CHPFh0rw1?uUat|zbP~r7bx7y|b!C5w( z5GW3y1Jfp-e?aNB3%(Y&-V5CxnRuw63cW7y;d+sE?Clu?b6OkJYHO92h{#C_v6ZC*B%1=!834Q@@hqn zOxVr}&ZV=Q?MOwn%&_xlA(~+kj!0=w3+l*)|$24<=gp2eWLtM z`Wl(~;$-*prdG<54LE1Evwn`yPTr>2Z8@jjah?En3X#$m&WXZImDDIXO3ISTkJ`sO z-hRBaL?Ig$an2lRcvLtI+H*PF&v9VE(f#So^c2jTwWXyc zaFZf+j#$|1()GHI(!)~ps`k%cY9$d`#a4@y2&2*!6=(ik4SkmfqpgN(NFZ{IxI0;d z&U$@^9kLi-u@#Dv(1}sU* zer({#Ke+#AP%f`(%?X1pc9^e#uFb|ua85fkC>bRos8{9lLm1rBX95nM2uuC30j~9qY_7et<06oK~ zsO5x?IZ;qJ@OnQ%eLI>`WX_p`W1a{p!-u~t$l5K+IL;($2vH{_tWJqocE+9-ONAx- zxoyWnuRhAaYQ$k{BY_ezcju5mJq)c)U+I}kXVrQ@!g)Nb=0|JukFqPjtKyRVNCe-= zy02tbq$KgZk_3SOHuD6yZ+igv@HC6yHqT%3rfq?1|A$?J&kWtm!3y%El?Ct?8-DPBWS^6Xv|Uril;-G`@_) zOu#9PxJUAzl!4|o>l8;0<^{>tM>Feg6LwYUEZNL5Sy{{Sl_~v~htA2>IJBQA`pOsQ ziZkH2U`TDre&y@IKzx(uq3>pGRmuKayA9Hn%)R!2l&U zZ1XruTkSz*fbDVTh`uwGb{-MUl;CB5fY0aU@A09t&3k>cg{pY$$rSruwJPIX?7JwF z|EJKR*R!e7O5wBk+1sdrxYqz~n?X!{c#ra~Afk6;vVCkmGqcKUW@qv07(~Kzt0P9L zbf6h-=!3M9MvawehBb2N{-EwXO7fLlow<`|yYYzb-pc%Q>B)>Rn+i^RpwPN<8I2S? z{iFm!AKmK=L<%_P4E)+?!-ULB36zEf5#QlVnabB=%jWFEP2{VBM*WC2>Wup{sm3M+ zR@hwAAncawcI~)8JQ~BB(cjU5ztkNJNAoZRM~ zm!7DT$BcD?8#ls|MXS_s`Ueccf>+*Q1x(4kF<(8wWcR9ub-kzer!dXM(`A)xLU5({ z;w87zyK!3!OLU{+Rad;%$4krH`h>pjjB2~Qs5>ovwLo?27Dvs{`>Vd`_nBQzNJWb^ z^Qrs8@~pxNbQ72dD20mShTU7>H=E7TcCxl+@UPK>rM3hsF&uiB4>c1pG30xw5k<+d zG^t{r2am8)X6RvY|JnX@C%?vdY=+LheY>^rpm-VmWSPA!$U{w5JtRR&eoHFJlC+8u z&bI(Blu)I`>~92Fb~ zd{KZ+5Eq01v9VV$#PxdY48Ylw@40+oQ-Z&)fPw!$1k;eC#!e$9`Rc`Nz#@xF&ZH(Q zU0m1Rj!(Uo3fynRnvNmyLk3;=@qCBfxkNz_pdCh|U}dE@K#F7lKGl5-`z~l1yYy8S zg;>oAC?HxdH}+a$Lh0eH@2@Ll9#B$1s-yz{eE2Z5BWt;`ks`VogS%N(u%4p^qIuIk zp5`FK1kZY?9n)uOcz%XHm>75w0E!b4c9Yc}==>2(NvX0Cf$7#pm5##Q z-jvB;tjr&WNB)M`hq50&RzQzd*z(vU>F6LoT?d&b`m0s0?DnHO5X!q^)n$JmIQwhv zGPhx&EMdAe9>4cxiudh-v@|s>$=wZJ3m`)2d@(=1ZSEh=2Sd>goij52Y_C5#iozw? z_-C9QDJ=z1M_;x+)gx8oJ8PXh8QFXQ$!6$Z`sPzN_IV_LY#CfP;YX^`AP~~G(nRtt5gDd& zH#9!~PLtXzGSRW^w(=b6{zQ&^^r-RPQ+VB*6@sxgAlpj5YF_Lf6j|w*j2_Ifjr^rw zBa|wh8us_=7|Xz01;mjwjRD#BK}P5u)e~5|)Wz{weDHFC`bsZ+!Vm8v8h^pF7bhyx z`Cqois(q}h#AiMnB^N?}HSL$uWBrd%+MC@}L9}^x*t7}}D>6X434YVToXG#;km!03 zmr>|5w}I2fV@V5+)*2GW@LzYJ15Rzhux#jrbV|ma%@+8jzvt%@v|SKHVev2i!jAsm zlt7T^ne8xHi1mzWMdaG;e2eQ{+Viw%J<^I4a-@?e+Xyn!nX3Q_{^GK(cUVmN#5sK~ zYh6WAFeS3wJ$T1J2gKh$He|D>uh{b@iWIYW{K<-=z+h?!wL3h3C@_Yk1`N{3y%D@H zplSX6{XyFmVANZCcLfynnh$BnrPGT^yD_`(#eW)BBD{wg*bSE5OexLe%-qd#fCtkq zc(VP8nc+aXr5ZMQuHIc~{p-)#;|Y}KNyy{$$2pG+9;3l6B7X-b zR~2XapTRlBnaqYbIXJ`??-45B??Q2D*Zi3IBd&CG3;F`83e|_Mf?D(_=NmUb7BRy| z>3hh9u${n$(x0~Nu1lR)2(@;NKXW2xboIuNZm|vIYMDi3~!^SlR7F{EyIHpax8b{ZB5oOMf3mT7ZW! zAneitzj$~Z%b@@LNDvnr>%cPcKM%3T3NSJDKMf(5{yt>r{qNzw9RAPeW5P%Q>(+xP zC-#fW*y9IY#-6xH?8?AD7Z3lJcKNGFz;do&RS)>r7_##cs2*&9?jJ4l4!n8A5F5+< z|9yd0^7->?KqYF=Cs7SBUThQ2RUATDS=pe^pQGzQQGU~ZdvuGOoE+RatD>R;ZV|$O zo`~3GMgFBPxZS+cZIi>-#Mt=%l=kKEP_}R1N^zIu?k)-8E}^WUu`g{>3PpyQu@-|F z`@X9bcVvsPFNIQOEEzL)60(zJ>}$4=-5~3GUZd{&_j{ht?|q;5^ZZxiy3Xr7kMlT= z^Ekf8ayBQ(`u&=8MT0TW7U!UGKhmfI_8L%kPoez1Oz_WZ(n{=@#hGgzh9ccE!_r)Z$>!3dsg^b>5}?E*?`O9c%DC|XCOx4a6nTW zShZ#w)|Agq{(c_K{NyS0YYJL1JntJY0>0!Q4`K^<{OvlC!Qv|5LS{>%8V$<$k`JJa zKPP|x3(fomdLlxmeog(vKgBYazY**~KnuR}E(>7?a}oLmP^l_!Mfz$&yrJPaSy%D=vC|-Y^XTSzXb-98uf2VTwNb^PO#mjiXF($S zHgpb#&QC2r{V}fAdk(YINm~K`|G)3V9n!LzB z_Ag6gjrShO;2+tV3r5XzVB+gu_#sjSf=de#_w^EU&?3Hx3%z++EY8@0MGbr_c~COf zNmUSia3LH)FZ7xwO|U}&U(HR`lT2`*)dTX?u4CCvVCacF1bCC6Q$)5kLgw3WvCAry z3<4|bTWB&!bulWp0iF%dv18mkyWig2=D~zE#DKlU1`FgNZ(TYa3r!oiW;Keb9r&+4 zNuR|RL+b+Cb8b~uu{!1klC94-h8flP7dEMR3glCb8!TqK7s*vRJM{~lnYK`pknIq1 z_I*suQIHB$Kw0;Pu$*P>hXgX{p&YkUrkujn9A1yg%6rNlh8}T_U){bx(E(4!{+k~ zBcY=)=A=p*^zp zIK2Gh51({R^}(y!GO_`Wr13n*GZf7@FS#oKTgz0C&n)Yy1`i?$$Eboos|&dS_z0sa z|6<&C1F6YB^EW>5%o)s#F}OQeMf$8(-E*yypN$?A8?8WP5J5Dz1 zbU~Y53bq{b+&9(x^DK|=QMo^9s8k$3d$;fQ_>q>W`L<#_RqU$6WLxOmPZ3{f?mxm(}X~K@VRD)o+|Wm%m%& zAd@~`87uVCN-W0nLGL_jfRzg_a0j_8k9u+2&0#s*3NK$$i?yC#NZCFd71}l3wU|G2 zGEkrQ?58*Ra(2VEdA!nad1q>>lC114z?NZ#Qy}~9TVbigc$iPEYTrX}#hbKy^ii%9 zDltW08vm0<9@Y&y>ckDn*Q-zXD}{9V)vN-J&c1Orq`9IY4)_7wCH(kh={dDHibqcJ zyjqd%P_L*#ujiTvFnrNc*x+*=f)-gulQipm`vS|Zyo&=mU!0PW7XD+nV2vmC+hj4T zny<99>mlGqN#J*nV^}Y3NB#zF02bze0*YIap8s9zwjEKZ{25ywrEu2rZG4ApmIF*+p|=rRnaht*!mx|1;Cm= zmuH<;D)$`SdEUM(7u@*nI4|$*1{-nX`Squ5T<_6YpTP($IPO5?2_2A-_IEQkb^E^#x{!qp%0?GjGnTpjhS& z-Cd`LwD3DRtOpL!Tp0f_gznB-y+E|0`!ZHKpoJ+qBldQ& zeMPXeSdn|h;K6a+IL+n&(fow$f)DLEvMd7&^~}ezuXiMAqY{v7xT4iOwdRUsG-b?V zyJUvcj7{z0d{)k}vo6m&h-o_pEHY+=pD@D)d85U?S1RZR6Ja&_nf>FK0$@czfO-*{ zIg#JHbDyJ+wyVuRPN7M!)QrEo^jKme@1*fdr5Uk_-K!|snG!A4jqFPJioxj~@mJTt zv5bAUiWow=z5bK;TVGw;vkm)B`-aajFOo@Oq4TBk-Uxt6l*j7GH+u~9&s={OY-qdU zP|*OMqEmmDM6*p=31>WQ7KGxBEVtoU^{iW)5w)@%kqD}zA42Lq2q6) z*@00ZkgV@nI5^sI^eyHg21zpS%*}n`zWlZ3#gqUyN$$Go<_Xz=-!EZFe`M@Z90Yv% zaF9(<-sI6fQ%T@{?&cguPc$s^y37+bm{F_Qm=6?e6Jf}AS64S=`L7B?m-}p|LTH4_UxX+LbC?}uy^Lq zMkE5^Uc==|^Q!#55I|DrErus$iFze}fmH8Cq>c^@T>JJzS`Qi(dov!E#sNUW7}8#b zwqrFkK9=s^L>QRQ4?DNEyqrhJ)skTsY164e$TkDH%0`!x7ab}9P;!taHa~q)nxp#5 z7kx5Fz+x5M1Z|mq%GRTJbSB|GNQoQtK~44P7M9J0E^Qe}TM5`dLeMu71WtB+q&PsC zjs}b|Iy;p6MIIyx`;@lVep>liTIFS0=^+Ylu0P$yV>1)x7ZX{bdLr^8sbJmh<`kFRldKmSqJ=aS-+Z0sFsh8aJW3*`*_>b$mvApXo*aK(#KX?RG}-x9 zL)Q;B#AU=CgdSq7txXF8E@m2I)s`pfLFOOawp52*xs2ftHIN73aU9{t!mV%~pSzXU z|6NjQKx)Cf#+dtb`TO>7O;hag+kXka5`HU85OzQy*a5|)u`16btYX^m(OOzd^CC!* z@r@bZP7^8$c#y_$+q30-W_(dRH~=+ydR4C8a+H2nz12tB z&B?806Ei$Fd3O{&J7mNzc|&p7!#W7wP*gdc3>?3Y1j8GhZa-u%;C<}v7cUGMyGy2vn*FUKLRDaf}%quqGT5voG^#91n?M|WO&gE`iYa!2MTYAa_JbC?)+3oXG1dRJ8qJS`c6!8L3>>5K9xE z+>W%mP+Kr3K%D6B-vuc|kVhK^(3QE=`QuW{KE@75%TTEvt6Q&9p0M}f(T>EzSe%f`F3oJ@{AK&^z1GBaPRPpz-rsjBLQ^P~;YcvD~L}_1@=*xPn zq{GzudLN1O2JHR*Xgc%W@F^NVvcEtL^duit<^;F*)`s&2JZ6?OZ+7}H4fY9O2+GG^ z@r~TkaRva5G5ohffq{{wo{6C+|Mf>7m%00z^v%sKO6W&XPbGCIvQU!7ncBL>`y`KV zVYhi1EpEwZ>m<@c$kCJ7sdk){wLV3V4jgrXhze019gGU4IgvHw5EL~4isuR2JHq`x zj1Fl=o ztnn1p)c9vG|OVy(s z_DHySf(nH9{DuNUB5>%%E!u=F#mWW^5oNiuBpEK>gUHbXEaCX zbbHy1w(6Faokn$%G&AEK;LWZQcQ&m?s3RjfzuwJynLv2KIeDBoT{+|}y^YiL7 zht7n97EWw7Hyf6~t0Kc2gb04w^~?|=F7W<`}>{5+CtRE{%&#)*f>`voZMD^FhDBAvg8)D^mz73U| z4HS_!SFO)flC=l(=NB~+ZK95)9glh`antulu9-I~&%zW28Ubo=tXPWjuFZKDljZZA zXZRY94A6sTZ7I1ly;4Lar&f-!RBgtKXKC4zk%fCJ_Le2FdMIH z@Xo&sif>3`J1-mNdT;U`ofsNa5D-`^-d^>O63ZV*%-W4lQo29b;b7WVhb+xNYD*?K zHn6l$>85H9;X+$odaTQZv*VpX?^2tFJ?e*inPX6}Pf1tR;{uB6cB;(98OfG@-lT}f z9UT?gZ=J}A4)Idbrp0@Q+!M{0`q)5~G&Tq7;)hrujy1Q(bCV<{LviThCi@FbGAKG}mAR9%Atuoxs*Xa`w9_R$8 zF74AX$8;M72irUq$f&mYviK|(`B_50?`}4_W$uIXOs%GJ;#h}Pkpv8fVX42d+a$et zvs}&pO^cv*yT6Eh4=5F?%?@oI{!I}1$-@=IVG_hb;Ta?c#|(u#MU|?-%|?QLarz;0F5! zu1}QSC%{<@M7*A8%4jcGWCqM?X0li}ic6aHI zCT5%OuSto!m?q-jjTZ&&E^I~PIU}YbMmm}A*_#W;V>HS+yiM1XZd8gfK{1DRkZx>< zblk`*Z&FK*T>!uZjdwt1W82$c3Fi#gtgH&cCEAYwD-R6pm>$RLb~O$fb1?56O@4uq zCv@=c?mKQ`kaC6ef9Ib9@~G#5ODi@XV{jn#4hpE)1qU9c{)Qj=_-n9(zA+>q$Fovv z6hU50wro;dz?*B{_li5Fns`cdW;)o|-mRzDSv56F(vHpjg8;6RgjQ66?lNS*S2^K? z;SS6|N{%K=3@5Rf!cZgKaf#-%i5@6H@OUkdTa@C*jEN|4%Ut_=_w#~A9YWN2#I}(Y zWmzj5ru8ROPS|)jOY44GQm&~KajqBEyQpX-((p`9q;bj(lNEW4RH7I*8uvD2vOXv0 zor$bgCC9s{`Im8WG$FAFla|m=IWCYi*ZtvrNwLzC*%U-`$!HtVpC_oarM~jhhY+E{ zBBhqW%x)TbaxnU)x{fBj)N4edi@I>K!-tFMqwi%!c`@#{Q8h4is!3&`gs?D1FQmin z&h0b7JVW=AaQR{EQu=4z^>1_+yCyU~AN?bv_Yw4kY?Aa97N(ja!#c{Mf@l7Nz08ON zqVeOllEPkd*vQ~8CYDnDDIp|%qTt258JId(G!yLC=neZavsVXP+hF|1W&de4S(y@3c zvGXixZSLsFCi^LbXSOpkf`WQu$ecdxlvJws;it~eU;NX*P%5b+tfS+uV6ZA~K4}8BjL}?W}L!VX3ZNGk%p>2I4M+6Sary=c=JRkXngMe4<8TrJa7K|{OuGtE z4y-!w?fX%O*Zyb4g`v6D)oqWd2OKH9HdE3o8vZQ}L1ft8%jfsUagSAUz!5vBTW=46{I8&kIg-46v~{_6$n~20lJDr#uN;L%K#mG*sva7!Ti5 z*Pz7Vvs=TWR-A|LBeO7SkdITy;G{30a`17LF+P z{H`OaF8Z2%!5bqXL#;T8c@3d_lvLO4++y*34Q1c-_Pip$)Nx^*{I8|fu^?<8!>XT> zR`tD$a=(jU6GoXi)r3EBuFf}*W8=yR9Lo%OA3S^8=uKemlKt=$!7n5 zkC$y@;@INKOM~dIJpq+5({bOw8~1U3zafjQe0d?NI*Tis-PMjiV>E7zOwAj3$77i3 z>SVTPAj~sJvW!_?`=$`(piMgTUA#g;DqYpB53}UWa`6%|+2>Tt1h`Dt^ z1x_^F<(#I6Mo7a8c^f#dd}g}Az{ZHiP^)FEYdWqkvmRHxzRzQCrHQxG(aswxr=s3N znd%iW1u}e6@C%|x{msNLVVxTgoHozW?@Vn?HH9AL!7KSF*+cKq7CJlJlS`)+8URWbInEAWs{`h)ANOti>^Jk|Uf^L<_NGioy<>;Q?t00h24Hca2 z6FM%y(4vB$m?y@yABY;iSxS4zF1nI-b}>k`>8NWQ#XYMgh&JQc%s;C}&@{jU__CG` z>s2N!(0|XAwY3>RM<@4%iaw=XuRuSKWQmKWwOkVDnNIc9IWBV&LRz>FES`x?>5``l z3*-EG+cZ|yh;lA@DQcQv#%w8->f7EP0FqcibGLWBW!ZfZB*Olg8NoeG)_B-Z3Sasu zd9=oLYb0Q@i@?6#D7hx{ZtVgKIHZsa4h}a+mW~k=@?eftIz3U;XmG8^yjX8ArfZdJ zbaXzV)%5ZmSzyiz=-lIS5*;}I8Z+-b5JNCy)icty{JENiT*NHvj)JSGAw$`wO_9y@ zN{{5FSBBXg)!U`3Nw}mar$S+#OI|eu!ZAMv6|5No_P*GFmGT(zABQs#dx6oLZ?_-O ztjtYZt&&ocE48+1lhZpocrQ&W+c4(55xV=Xa+a=&^Z288oZ}h>?nX5cB-h9eHXS5Q z8OBh&fs=bL6#zcs_}kBv-wdTv5mX|IdB$5!wdwK0BMmNczoR@hzATQ)%ZWS^H0XQ! zbW^398?KTWg(%evrc9Y~CHsyI{G#GCe|0sa4&5Cr5_87_q4jozxPmONyQe2!4W=us zS1^XwUG~}0>l-Ebo5^zpWooC#Q`PKN1z(gc-mpFPIYWp3NPYvDF-h*}`)FS0?57zc zn?=$HPc&5pw`V7zodZfeap)#s_;+&>3JljJWebf;Jxs3o_rG>FoTY3=YP7w&(z^8O z&UJhQrZ97lFAg3IrOpqv**|OD-c0@ydcaeQm6D5y`nNB@aqtME2tP;{!{qDg)b2;V zOl+!01&{1VOTs3yQ} z*s=L9ha;$5G5l5|BhJ7H%;G6Y+dCbE4m?6w{p@-C}UndSVMkDclrvK8wBXOah zVq1Fa%0A@ZN7649K${SRX8_;*=MmA6BNG7^L zOK2gs?dd9TJpHm`U1YqGr5_nUSPP+(p!C&c^J|c@yHZbo|2+Ez57e zUs^H;8u&CjyC`p-MXN<~!jN8>0cP5GEvg|X`B6TKxxMk!6VfdbntFFpya zTbkw~Ox#2%>kTFr0U^0#@zYv$^&G9QDyW`oA?-U{@~c^6V?MwXHeD~Cf7=psj91;a z*mFKOC1a|ItwM;y(lnTpUpU>*AhoDh^GM5qM^KZG*o$TCqnaUo=I!paZRTjugu2FU`O1a zk8|64mTTlC73Bee4C`^(AbuQNCyRCXoP7GspsQ?KOl0x^;78^oP+yix{|g^Js3F*e zbc16lCuiTACl#u;zqFbIyG;l!w;%o8S`0t(+fjlfP{9`0@-o&+Ghhe%T!ueJe07^T zR*wbgCKsA>JYIKWSH6+q5rIK7dFZCg>Sx+iJ0{0~FI@Y*F734M{;U}1nh;fz5?%&6 zbQK&-pOoP{mmJCcjzYVjWqVWgRI(iPMXjO7JBeqEJS9z2W&0RM`^xT4DDatukx%q$ zo(EhD3u{DbrQAuZA9$qRYaaaKNEbs@Du5$4h#t|cb_W>YUy9X3hCWrCII`wA*SA)1 z)BejVi+fL2WHJrUqbV*v)1f!Z5vT({`f^MZx5IMCwyVH!DpK+cS#xVxQHheU-B7}O zKsvi#nF($9K2?4*jhGa9ViWilB>F~;P14cN`Q9Tx@r>4t9S2B`@HQXi(WFZV;H`$} zq=ya%I-f3orajah@uh}G^Gm43^r+WrPa4E!lz1d}4N1{-X5+i1Sft^4v=P6;rWM=v zvt!4ccX!s(S0Zv&o|iA)m@nTNHwkxjZW1i#2FW}rJPuJj2YM~_bDfChYdOfX*5;l* zJKaND6I~iL(h9Hevm(()*G4MKA|I0u;%So=Tc>n-7F+*I0`k5Woy(+ zddersyZz98VbC*m#jncWbCgTpIRXc}%5Mlx!4)yh1h?KLo!+fl?KT9s#+f~RRQ{V? zLnxg)>mGuPMSD-gdZq>O(u1a;Q@zxaeT&ShIZG+yQ`w?r&;0N<*Y7Q5i)H}y3z!#a z>aSD27N(_judi=f4QV7zcTAzT!@&WGsX1nr3*yBWwB^b-ru`%q`W+1nl1qn+l&^_+ zcYRnrgxdM50<6CY+xhhYJmr!23_v^I+YX(~wYDDGiQZt(j2h((G425lx3==FnlFGd zuWW~yi*1s9l~m+R+BBJ&ibW+Pygt7;HG4N@*5S?Dqhtl?0@wLIGcE6pOIUl20ocYt93pCBHq5_3>O56$ZKh9vLPst&+zkMHIU+{%#d$!2EgUzginjYK9IDDBgKDHBucJ?-< zDL1+p5SE$hkCnjhL&N(XB)~U0nenl5nQa)oxsD{sml2x-KAzXMchs^PAL(Sq??JWc z_yO{lDG3aOE$GRGvnaX6mpt=(Lioq3v?`AApP$<}YPVfhqXffy<&YEa|5)oUTnC!} zrHbd);_8O?d1e8U-N-c$cK1rnTS})hza~Lfj9dU*HGL3-5zq1hv>u_lzmecyEh8db z;ZM1f2)Bd@yJA|{$k1Pv*9{nieq%0 zk$_4S=gZ4j)lQ&GI!wuX(Tbh(@gi zk+&ni?wO@N0(Biw)I;-@rfy0~qGo=x;p$PnWza z^1d5&W9ow?RgGH5AsVD$p>waMRP^mAlIYXTX@!NC)?8q+K==SJjcz|6bc%#^(zr zH!5hMUPuwbWCv6$*BpvoL^YTd3x_b(aFi9#cz4FZ@P-#a)*st2k;6RFsiUty%~^}f zOKl0S(l4y6ZFPGSa$hLCd@7bhhNZq|KmsOnkUf&sgp`S%&Qrsi6l$+EVr1x}W_LDy zVV*9bnJ87Cjj3~t47mn3nuN~MZ{IhqGz-@t`u&jha`iO7DEkhE;zi?z4lKdyYUHtS z*HXIV+1qXH0Ug7MzFQzOZ19JWG>2?v@kECdXEXw$MW2cJ{(5^DEWIKNE z=D8lW8ZyNpn)>2Bm%Kg^{c8PVMQ*Jhz{z5cj7^4;6q_;Yb6NV%4|TAeLTG2?6$4*= z&ah_`jdG8#&q1k~hT}zgiQb{zoygH#l5I&VDy5P0s0W_UK zJIm2Wsc!_GRp@yuhFC&Xu1V~_0f!HBM~HRU_w>?CWJ6DhKAHvhn}2D=8F!{qq}OkQ)=S5)7m-1y z9|$r*zj1%Py->LfRQ}C7UUcBJ&g8DEt6!NS9E^dTiW^bkzm$)1b4vu)0_UL3e(-lv zL)CN48y%YuPk{+MXY*SOMH>XWE{xU)efUuSW0|o3r&>{z#CPZEf>iTUG$<6pU#34G zs^2!n3q5~+;sZcOX+OPDnwe_%nymodtH_@aB6GzC{+p61E{ydWoTbOjdjCbfy%n50 z%*5pA=-A%Y*113C6hg&J1xFzj6r7^LDR19GMF)YP=%B|Tll~%Ku0y?`pil-O3p8qi zv||QBNN|j#X2v(<7{)tO2{Mn(s>6;}py{;F3Z)ZLa#QN3k#IOv?tD28(nKA%N{bkX z42o^^VLDff_CpbBx;xJa3Auswfr9uMP&xn<8%T4S8^DA1U0m?2tV0J)<@plndW>1w z*pk7d|DFNpz66@a!9F&|MW*^U!;TBh*E~o*6Ij?t7OsbJ*(LS zE)qgo;$GVAy+iFF$h)3Aa@y^^D$Dqmd)Z|$2B3%sx-*zFzp5&dnaksO@jiN543#49 z-8q>g@3ST2`FI}`LT&&3*s*&ctsr5abYs1aG&CX}aq6P+<*Qe(nwq9<&30uc?>1rN z>1h#ChKv6-Pkzf5-p3B&$8<5UN3yPS`fGCoXn(S+p8{pBAfk(AKHccW zpl$XBbo`RA_#LbiIu`IXpp?3*>ip>E_dcLNhDHPGU!ygYO(7AzSNCzh(s+1}HuE5S`!h1U5Bew=6Z~QS0AYOmXNXtq zgKM@2$HL<1&;N%aN|BTd3UJf*Lcp#u(wdeXH)WmPaR@Q|n&rOUfk*PrT54(;`G3!c zBP{lP)n`F7Gm4=&D085Nu7g|W7+!!GT*PM=X!%TCtx$!vRS{rIWuOrnjYt$-3xB5f ztK*wICUCI3(IHDu7M{;T|V%K?m;l}`{a@^Kdv!|+cR+oYN zq4U|)VR}vVh??;pPzyL6lnq1(-252C|7H?2tpTKbgxEIil(fVsryml5~ld$IT^(;?Zh%GL0sBMdI_?UA|E7~VRzNuS)?>uuG^ z$R3iH0ZQ=6t3FzaEPM$5E<-j@BxXZQKa_VK}2pQr4p@#0_pf*-#`J~&m?+m)Bh}q z9%4vp9D=2~r8Z8XvOYTgs4eUBPKi&LMo{ayBR7H&O12&<%L9pj5)N)|r65zTl}Fjr z#k|~SV46~_boQ)*(oXB`Xr&JBXmM?=38cmqZu{26keVupTPZ{60sEHo)FMjqro(?P zbWhvGSacHdTpds>wqENnBue1@G{*idCmyr;$JD2Z3DFO6 zwe6arUtCUPqol<^n)~W7C{M1ve!cC`q4M^cNXFZ0E_IhRG+ru%(4_472Ekw6XV##5 z`XgbZ6;X=1-~up)LC~iZq%yCskE%zB6*^5-g3MzqqY*Xwl8sktXA>xqyjCTUNqI4`I$pXX$CK2FoXu!o(l^X!(M38Z_b4}aL5G#7RD8By<*@0&aIy`TlN z`Im*m;@2s_@wpsVHVC~Hy4%MZx;isaHJFc>J6z(@zXqZAO$fJiv{_g<#5-PDI5knr z7F`;tlc&y&NLtfaD`V+z+k)f8r8Pl6s%1w%70=$O+Z%_}i+LU>)KXUx+tPyK~B2oS}|3X|IVolE-#Wo zb6nU$G04U=GyeQSKqQoy^slAiK+dpDv}Pfbd0sM(DlcdGGBxjPdgF^zDyN^`!ups8 z*&ouhQF>|Z%);-UIjb0x=ab=80^jbkEf+aac;ouh+qTZbABQ5N=A`-!O3+QG8yx+3 zJihtFhFIt+O9L2P&m-%?1xKp*c#qF^QC$p|-1Qmrn|rF#mWWwnZpO%d3EpHdCvi7= z`_aT6_Re{^{Gouk3n#dLQaY9;#B(?Nc}LMcNJc3IN;66Oj^8izDd@osW>`48&eyze zp&hmO4;pt*T3G> zfcMJBr#ICXc|KLZE14}?W!tNbpq>VyekD&ZweBO}+4;is;3Lx3y`}?2?x1Wa zn~Jqg{zNksE;b4xt*M>ePg-*C$0qjiSNc1E9>98bbNwI(0o%0{C1Q(vJW}ZyWMgLB zlH_{*LvPGdEd?K7EuY$34pk|Hn+;d}OQkimWxxfU%ETZkakZrXkDOu(cdIJV`!H?o zbQfpTvss89=e|;|9BuUYv9!-y&jWu}S$t@kLWB{0cDIeVHoGylcA)b>d%Mw4sr--c zJ&ABM>F4{yQ$&owiqEFC2~hMaT!p=5Lon>b^&3(zAA?RFUshRyz(r?r-IDkcL4}f} zRb=?$^W#0yqTZ+2r1d$^fEt~*=1@&G;0$Kak;(0U(>WP;;gr%dh&QlwF*?f9GGIk* zC=%%K?HWi&lMEfA0}DT31DfUiZ`|^evoKar8FZE0l_6RgG#Co6+{mFt;}ZDL+P~)L z)3|t}5@hlI&4)Cjej)TJ>yjmmM7GtaB_TWFk%Uu&#~zzrY;9sxo__q~4Xs3QAF zZqx}`+qY%wy&JL1D#};-v^w_;3YLEy1b@1{w9)G7>b=iDO-VT`uIl1K)-yM_%gb~= z=mL{akUkQ*_Z`jO%};|ytz%~r3!AP4|4R-lP27~Oc-W>ePkn}ZvL3>i?lTiNGcgMX zotJ1P7GCrBnwyLorrIE_PCK#_2M|KC24I_Qk7137YEcKNB?fZ3O@;x=7FY zC>WRH_p`W;k8C=VbYLU)ozc3*>~-(|{)hc^fRPSDyAA#U%?(VBj_y&-majy|W^iWX S2o?N9cS}X%de&9rC;tb^@x?Fz literal 0 HcmV?d00001 diff --git a/tests/lib_and_user_data.uml b/tests/lib_and_user_data.uml new file mode 100644 index 000000000..618a012e2 --- /dev/null +++ b/tests/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/lib_and_user_data_test.py b/tests/lib_and_user_data_test.py new file mode 100644 index 000000000..f6ae70c4a --- /dev/null +++ b/tests/lib_and_user_data_test.py @@ -0,0 +1,245 @@ +# 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 + +import defcon +from glyphsLib import classes +from glyphsLib.builder.constants import GLYPHLIB_PREFIX +from glyphsLib.designSpaceDocument import (DesignSpaceDocument, + InMemoryDocWriter) + +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(writerClass=InMemoryDocWriter) + 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(writerClass=InMemoryDocWriter) + 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' diff --git a/tests/run_various_tests_on_various_files.py b/tests/run_various_tests_on_various_files.py index 1ff790c23..2ca516ccb 100644 --- a/tests/run_various_tests_on_various_files.py +++ b/tests/run_various_tests_on_various_files.py @@ -19,18 +19,18 @@ import re import glyphsLib -from glyphsLib.designSpaceDocument import DesignSpaceDocument +from glyphsLib.designSpaceDocument import (DesignSpaceDocument, + InMemoryDocWriter) import test_helpers -# Kinds of tests that can be run - +# 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 = glyphs_files(directory(testable)) + files = test_helpers.glyphs_files(directory(testable)) for index, filename in enumerate(sorted(files)): def test_method(self, filename=filename): @@ -38,7 +38,7 @@ def test_method(self, filename=filename): file_basename = os.path.basename(filename) test_name = "test_n{0:0>3d}_{1}_v{2}_{3}".format( - index, testable['name'], app_version(filename), + 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) @@ -50,7 +50,7 @@ class GlyphsToDesignspaceRT(unittest.TestCase, @classmethod def add_tests(cls, testable): - files = glyphs_files(directory(testable)) + files = test_helpers.glyphs_files(directory(testable)) for index, filename in enumerate(sorted(files)): def test_method(self, filename=filename): @@ -60,22 +60,23 @@ def test_method(self, filename=filename): file_basename = os.path.basename(filename) test_name = "test_n{0:0>3d}_{1}_v{2}_{3}".format( - index, testable['name'], app_version(filename), + 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): +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 = designspace_files(directory(testable)) + files = test_helpers.designspace_files(directory(testable)) for index, filename in enumerate(sorted(files)): def test_method(self, filename=filename): - doc = DesignSpaceDocument() + doc = DesignSpaceDocument(writerClass=InMemoryDocWriter) doc.read(filename) self.assertDesignspaceRoundtrip(doc) @@ -85,6 +86,7 @@ def test_method(self, filename=filename): 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): @@ -147,32 +149,6 @@ def add_tests(cls, testable): ] -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) - - def directory(testable): return os.path.join( os.path.dirname(__file__), 'downloaded', testable['name']) @@ -191,7 +167,8 @@ def directory(testable): 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([__file__, *sys.argv])) + sys.exit(pytest.main(sys.argv)) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 09bca6743..1638c95e2 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -15,18 +15,23 @@ # limitations under the License. import difflib -import sys 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 import classes from glyphsLib.designSpaceDocument import (DesignSpaceDocument, InMemoryDocWriter) from glyphsLib.builder import to_glyphs, to_designspace from glyphsLib.writer import Writer from fontTools.misc.py23 import UnicodeIO +from ufonormalizer import normalizeUFO def write_to_lines(glyphs_object): @@ -49,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) @@ -83,6 +88,7 @@ def assertParseWriteRoundtrip(self, filename): 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 @@ -98,7 +104,8 @@ def assertUFORoundtrip(self, font): self._normalize(font) expected = write_to_lines(font) # Don't propagate anchors when intending to round-trip - designspace = to_designspace(font, propagate_anchors=False) + 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) @@ -107,7 +114,7 @@ def assertUFORoundtrip(self, font): directory = tempfile.mkdtemp() path = os.path.join(directory, font.familyName + '.designspace') - designspace.write(path) + write_designspace_and_UFOs(designspace, path) designspace_roundtrip = DesignSpaceDocument( writerClass=InMemoryDocWriter) designspace_roundtrip.read(path) @@ -129,13 +136,100 @@ def assertUFORoundtrip(self, font): "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) + + 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: + 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: + 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): - font = to_glyphs(designspace) - font.save('test_font.glyphs') - # roundtrip_in_mem = to_designspace(font) - # # TODO: tempdir - # font.save('lol.glyphs') - # font_rt = GSFont('lol.glyphs') - # roundtrip = to_designspace(font_rt) - # # TODO: assert designspace + UFOS are equal! + 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) + + tmpfont_path = os.path.join(directory, 'font.glyphs') + font.save(tmpfont_path) + font_rt = classes.GSFont(tmpfont_path) + roundtrip = to_designspace(font_rt) + + font.save('intermediary.glyphs') + + # 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/writer_test.py b/tests/writer_test.py index 06300dfe4..4774a235b 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -794,6 +794,7 @@ def test_write_layer(self): name = "{125, 100}"; paths = ( { + closed = 1; } ); userData = { 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 = From f99f83dbdc4ef06f52ad92406cc5a9aa66c4dc42 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 13 Dec 2017 17:41:29 +0000 Subject: [PATCH 06/44] Use PLIST for Designspace Document lib --- Lib/glyphsLib/designSpaceDocument.py | 34 ++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/Lib/glyphsLib/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py index e4cce59f6..84b1519f6 100644 --- a/Lib/glyphsLib/designSpaceDocument.py +++ b/Lib/glyphsLib/designSpaceDocument.py @@ -15,7 +15,7 @@ import logging import os import posixpath -import json # FIXME: (jany) that's for lib, should use plist xml I guess +import plistlib import xml.etree.ElementTree as ET from mutatorMath.objects.location import biasFromLocations, Location @@ -34,6 +34,30 @@ ] +def to_plist(value): + try: + # Python 2 + string = plistlib.writePlistToString(value) + except AttributeError: + # Python 3 + string = plistlib.dumps(value).decode() + return ET.fromstring(string).getchildren()[0] + + +def from_plist(element): + if element is None: + return {} + plist = ET.Element('plist') + plist.append(element) + string = ET.tostring(plist) + try: + # Python 2 + return plistlib.readPlistFromString(string) + except AttributeError: + # Python 3 + return plistlib.loads(string, fmt=plistlib.FMT_XML) + + class DesignSpaceDocumentError(Exception): def __init__(self, msg, obj=None): self.msg = msg @@ -497,7 +521,7 @@ def _addInstance(self, instanceObject): if instanceObject.lib: libElement = ET.Element('lib') # TODO: (jany) PLIST I guess? - libElement.text = json.dumps(instanceObject.lib) + libElement.append(to_plist(instanceObject.lib)) instanceElement.append(libElement) self.root.findall('.instances')[0].append(instanceElement) @@ -549,7 +573,7 @@ def _addSource(self, sourceObject): def _addLib(self, dict): libElement = ET.Element('lib') # TODO: (jany) PLIST I guess? - libElement.text = json.dumps(dict) + libElement.append(to_plist(dict)) self.root.append(libElement) def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): @@ -889,7 +913,7 @@ def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerni def readLibElement(self, libElement, instanceObject): """ TODO: (jany) doc """ - instanceObject.lib = json.loads(libElement.text) + instanceObject.lib = from_plist(libElement.getchildren()[0]) def readInfoElement(self, infoElement, instanceObject): """ Read the info element. @@ -981,7 +1005,7 @@ def readLib(self): """ TODO: (jany) doc """ for libElement in self.root.findall(".lib"): - self.documentObject.lib = json.loads(libElement.text) + self.documentObject.lib = from_plist(libElement.getchildren()[0]) class DesignSpaceDocument(object): From 0ad00da598d9522471179184d9acd0fde12abb0e Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Fri, 15 Dec 2017 17:28:24 +0000 Subject: [PATCH 07/44] Fix the groups + other WIP stuff --- .gitignore | 4 + Lib/glyphsLib/builder/builders.py | 36 ++-- Lib/glyphsLib/builder/custom_params.py | 11 + Lib/glyphsLib/builder/glyph.py | 14 +- Lib/glyphsLib/builder/groups.py | 190 ++++++++++++++++++ Lib/glyphsLib/builder/kerning.py | 91 +++------ tests/{ => builder}/builder_test.py | 0 tests/{ => builder}/lib_and_user_data.png | Bin tests/{ => builder}/lib_and_user_data.uml | 0 tests/{ => builder}/lib_and_user_data_test.py | 34 ++++ tests/{ => builder}/roundtrip_test.py | 2 +- tests/builder/to_glyphs_test.py | 179 +++++++++++++++++ tests/test_helpers.py | 10 +- 13 files changed, 475 insertions(+), 96 deletions(-) create mode 100644 Lib/glyphsLib/builder/groups.py rename tests/{ => builder}/builder_test.py (100%) rename tests/{ => builder}/lib_and_user_data.png (100%) rename tests/{ => builder}/lib_and_user_data.uml (100%) rename tests/{ => builder}/lib_and_user_data_test.py (87%) rename tests/{ => builder}/roundtrip_test.py (94%) create mode 100644 tests/builder/to_glyphs_test.py diff --git a/.gitignore b/.gitignore index af0f3c117..f0876ad6f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ htmlcov # Autosaved files *~ + +# Files generated by tests +actual* +expected* diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 7bee6f89b..8e65a78e4 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -116,7 +116,6 @@ def masters(self): """ if self._ufos: return self._ufos.values() - kerning_groups = {} # Store set of actually existing master (layer) ids. This helps with # catching dangling layer data that Glyphs may ignore, e.g. when @@ -134,7 +133,6 @@ def masters(self): self.to_ufo_font_attributes(self.family_name) for glyph in self.font.glyphs: - self.to_ufo_glyph_groups(kerning_groups, glyph) glyph_name = glyph.name for layer in glyph.layers.values(): @@ -153,8 +151,10 @@ def masters(self): ufo_glyph = ufo.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, glyph) ufo_layer = ufo.layers.defaultLayer - ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + - glyph.name] = self._layer_order_in_glyph(layer) + if self.minimize_glyphs_diffs: + ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + + glyph.name] = self._layer_order_in_glyph( + layer) for master_id, glyph_name, layer_name, layer \ in supplementary_layer_data: @@ -181,9 +181,10 @@ def masters(self): else: ufo_layer = ufo_font.layers[layer_name] # TODO: (jany) move as much as possible into layers.py - ufo_layer.lib[GLYPHS_PREFIX + 'layerId'] = layer.layerId - ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + - glyph_name] = self._layer_order_in_glyph(layer) + if self.minimize_glyphs_diffs: + ufo_layer.lib[GLYPHS_PREFIX + 'layerId'] = layer.layerId + ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + + glyph_name] = self._layer_order_in_glyph(layer) ufo_glyph = ufo_layer.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, layer.parent) @@ -191,12 +192,11 @@ def masters(self): if self.propagate_anchors: self.to_ufo_propagate_font_anchors(ufo) self.to_ufo_features(ufo) # This depends on the glyphOrder key - self.to_ufo_kerning_groups(ufo, kerning_groups) for layer in ufo.layers: self.to_ufo_layer_lib(layer) - for master_id, kerning in self.font.kerning.items(): - self.to_ufo_kerning(self._ufos[master_id], kerning) + self.to_ufo_groups() + self.to_ufo_kerning() return self._ufos.values() @@ -261,11 +261,11 @@ def instance_data(self): from .features import to_ufo_features from .font import to_ufo_font_attributes from .glyph import to_ufo_glyph, to_ufo_glyph_background + from .groups import to_ufo_groups from .guidelines import to_ufo_guidelines from .hints import to_ufo_hints from .instances import to_designspace_instances - from .kerning import (to_ufo_kerning, to_ufo_glyph_groups, - to_ufo_kerning_groups) + from .kerning import to_ufo_kerning from .masters import to_ufo_master_attributes from .names import to_ufo_names from .paths import to_ufo_paths @@ -348,22 +348,22 @@ def font(self): self.to_glyphs_ordered_masters() self._font = self.glyphs_module.GSFont() + self._ufos = OrderedDict() # Same as in UFOBuilder for index, ufo in enumerate(self.ufos): - kerning_groups = self.to_glyphs_kerning_groups(ufo) - master = self.glyphs_module.GSFontMaster() self.to_glyphs_font_attributes(ufo, master, is_initial=(index == 0)) self.to_glyphs_master_attributes(ufo, master) self._font.masters.insert(len(self._font.masters), master) + self._ufos[master.id] = ufo for layer in ufo.layers: self.to_glyphs_layer_lib(layer) for glyph in layer: self.to_glyphs_glyph(glyph, layer, master) - self.to_glyphs_glyph_groups(kerning_groups, glyph) - self.to_glyphs_kerning(ufo, master) + self.to_glyphs_groups() + self.to_glyphs_kerning() # Now that all GSGlyph are built, restore the glyph order for first_ufo in self.ufos: @@ -437,11 +437,11 @@ def _fake_designspace(self, ufos): 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_glyph_groups, to_glyphs_kerning_groups, - to_glyphs_kerning) + 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 diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 2f4091a41..26d3c712a 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -271,6 +271,17 @@ def register(handler): for glyphs_name, ufo_name in GLYPHS_UFO_CUSTOM_PARAMS: register(ParamHandler(glyphs_name, ufo_name, glyphs_long_name=ufo_name)) +GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME = ( + 'openTypeHheaCaretSlopeRun', + 'openTypeVheaCaretSlopeRun', + 'openTypeHheaCaretSlopeRise', + 'openTypeVheaCaretSlopeRise', + 'openTypeHheaCaretOffset', + 'openTypeVheaCaretOffset', +) +for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: + register(ParamHandler(name, name)) + # convert code page numbers to OS/2 ulCodePageRange bits register(ParamHandler( glyphs_name='codePageRanges', diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index 5cb36ce0a..bb63ea208 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -25,6 +25,7 @@ PUBLIC_PREFIX) SCRIPT_LIB_KEY = GLYPHLIB_PREFIX + 'script' +ORIGINAL_WIDTH_KEY = GLYPHLIB_PREFIX + 'originalWidth' def to_ufo_glyph(self, ufo_glyph, layer, glyph): @@ -70,8 +71,7 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph): ufo_glyph.font.lib[postscriptNamesKey] = dict() ufo_glyph.font.lib[postscriptNamesKey][ufo_glyph.name] = production_name - for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey', - 'leftKerningGroup', 'rightKerningGroup']: + for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey']: value = getattr(layer, key, None) if value: ufo_glyph.lib[GLYPHLIB_PREFIX + 'layer.' + key] = value @@ -102,7 +102,8 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph): 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 + # 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 @@ -176,8 +177,7 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): layer = self.to_glyphs_layer(ufo_layer, glyph, master) - for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey', - 'leftKerningGroup', 'rightKerningGroup']: + for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey']: for prefix, object in (('glyph.', glyph), ('layer.', layer)): full_key = GLYPHLIB_PREFIX + prefix + key if full_key in ufo_glyph.lib: @@ -203,8 +203,8 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): layer.width = ufo_glyph.width if category == 'Mark' and sub_category == 'Nonspacing' and layer.width == 0: # Restore originalWidth - if GLYPHLIB_PREFIX + 'originalWidth' in ufo_glyph.lib: - layer.width = ufo_glyph.lib[GLYPHLIB_PREFIX + 'originalWidth'] + if ORIGINAL_WIDTH_KEY in ufo_glyph.lib: + layer.width = ufo_glyph.lib[ORIGINAL_WIDTH_KEY] # TODO: check for customParameter DisableAllAutomaticBehaviour? self.to_glyphs_background_image(ufo_glyph, layer) diff --git a/Lib/glyphsLib/builder/groups.py b/Lib/glyphsLib/builder/groups.py new file mode 100644 index 000000000..a36f3cf3c --- /dev/null +++ b/Lib/glyphsLib/builder/groups.py @@ -0,0 +1,190 @@ +# 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 ufo in self._ufos.values(): + for name, glyphs in groups.items(): + # Shallow copy to prevent unexpected object sharing + ufo.groups[name] = glyphs[:] + + +def to_glyphs_groups(self): + # Build the GSClasses from the groups of the first UFO. + groups = [] + for ufo in self.ufos: + for name, glyphs in ufo.groups.items(): + 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, ufo in enumerate(self.ufos): + if index == 0: + reference_ufo = ufo + else: + _assert_groups_are_identical(self, reference_ufo, ufo) + + +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) have a real implem? + # 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)) + reference_glyphs = reference_ufo.groups[group] + if glyphs != 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(glyphs)) + _warn(" current = %s", ' '.join(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/kerning.py b/Lib/glyphsLib/builder/kerning.py index e9a21084a..afb4f2fe9 100644 --- a/Lib/glyphsLib/builder/kerning.py +++ b/Lib/glyphsLib/builder/kerning.py @@ -15,20 +15,17 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -import logging import re -from collections import defaultdict -logger = logging.getLogger(__name__) +UFO_KERN_GROUP_PATTERN = re.compile('^public\\.kern([12])\\.(.*)$') -GROUP_KEYS = { - '1': 'rightKerningGroup', - '2': 'leftKerningGroup'} -UFO_KERN_GROUP_PATTERN = re.compile('^public\\.kern([12])\\.(.*)$') +def to_ufo_kerning(self): + for master_id, kerning in self.font.kerning.items(): + _to_ufo_kerning(self, self._ufos[master_id], kerning) -def to_ufo_kerning(self, ufo, kerning_data): +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.' @@ -40,7 +37,7 @@ 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) + # self.logger.warn(warning_msg % left) pass for right, kerning_val in pairs.items(): match = re.match(r'@MMK_R_(.+)', right) @@ -48,7 +45,7 @@ def to_ufo_kerning(self, ufo, kerning_data): if right_is_class: right = 'public.kern2.%s' % match.group(1) if right not in ufo.groups: - # logger.warn(warning_msg % right) + # self.logger.warn(warning_msg % right) pass if left_is_class != right_is_class: if left_is_class: @@ -60,23 +57,11 @@ 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) - - -def to_glyphs_kerning(self, ufo, master): - """Add UFO kerning to GSFontMaster.""" - for (left, right), value in ufo.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? + _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. """ @@ -97,7 +82,7 @@ def _remove_rule_if_conflict(ufo, seen, classname, glyph, is_left_class): if (existing_rule is not None and existing_rule[-1] != val and pair not in ufo.kerning): - logger.warn( + 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))) @@ -112,45 +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 - 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_glyphs_glyph_groups(self, kerning_groups, glyph): - """Write kerning groups to the GSGlyph. - Uses the ouput of to_glyphs_kerning_groups. - """ - for group_key, group_name in kerning_groups.items(): - setattr(glyph, group_key, group_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_groups(self, ufo): - """Extract all kerning group information from UFO. - Return a dict {glyph name: dict {rightKerningGroup: leftKerningGroup: }} - """ - result = defaultdict(dict) - for group, members in ufo.groups.items(): - match = UFO_KERN_GROUP_PATTERN.match(group) - if not match: - continue - side = match.group(1) - group_name = match.group(2) - for glyph_name in members: - result[glyph_name][GROUP_KEYS[side]] = group_name - - return result +def to_glyphs_kerning(self): + """Add UFO kerning to GSFont.""" + for master_id, ufo in self._ufos.items(): + for (left, right), value in ufo.kerning.items(): + 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/tests/builder_test.py b/tests/builder/builder_test.py similarity index 100% rename from tests/builder_test.py rename to tests/builder/builder_test.py diff --git a/tests/lib_and_user_data.png b/tests/builder/lib_and_user_data.png similarity index 100% rename from tests/lib_and_user_data.png rename to tests/builder/lib_and_user_data.png diff --git a/tests/lib_and_user_data.uml b/tests/builder/lib_and_user_data.uml similarity index 100% rename from tests/lib_and_user_data.uml rename to tests/builder/lib_and_user_data.uml diff --git a/tests/lib_and_user_data_test.py b/tests/builder/lib_and_user_data_test.py similarity index 87% rename from tests/lib_and_user_data_test.py rename to tests/builder/lib_and_user_data_test.py index f6ae70c4a..d8972d62d 100644 --- a/tests/lib_and_user_data_test.py +++ b/tests/builder/lib_and_user_data_test.py @@ -18,6 +18,7 @@ import base64 import os import pytest +from collections import OrderedDict import defcon from glyphsLib import classes @@ -243,3 +244,36 @@ def test_node_user_data_into_glif_lib(): 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(): + # 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]) + 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 94% rename from tests/roundtrip_test.py rename to tests/builder/roundtrip_test.py index 658e09db6..28d5f54fa 100644 --- a/tests/roundtrip_test.py +++ b/tests/builder/roundtrip_test.py @@ -31,7 +31,7 @@ def test_empty_font(self): def test_GlyphsUnitTestSans(self): 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..ac05c8b21 --- /dev/null +++ b/tests/builder/to_glyphs_test.py @@ -0,0 +1,179 @@ +# 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 defcon + +from glyphsLib import to_glyphs, to_ufos + +# 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: add a test with 2 UFOs with conflicting data + # TODO: add a test with with both UFO groups and feature file classes + # TODO: 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 diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 1638c95e2..bb7b7d6e9 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -192,15 +192,21 @@ def assertDesignspaceRoundtrip(self, designspace): 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) + 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) + 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: + normalizeUFO(source.path, floatPrecision=3, writeModTimes=False) + write_designspace_and_UFOs(roundtrip, 'actual/test.designspace') + for source in roundtrip.sources: + 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") From 12b8226be38dc93e482c2b8f1f432e916afe9953 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 18 Dec 2017 16:55:03 +0000 Subject: [PATCH 08/44] Hide bool vs int diffs from UFO-UFO roundtrip --- tests/builder/lib_and_user_data_test.py | 10 +++++- tests/test_helpers.py | 41 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/builder/lib_and_user_data_test.py b/tests/builder/lib_and_user_data_test.py index d8972d62d..39bdda5a3 100644 --- a/tests/builder/lib_and_user_data_test.py +++ b/tests/builder/lib_and_user_data_test.py @@ -246,7 +246,7 @@ def test_node_user_data_into_glif_lib(): assert path.nodes[4].userData['nodeUserDataKey2'] == 'nodeUserDataValue2' -def test_lib_data_types(): +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({ @@ -266,6 +266,14 @@ def test_lib_data_types(): 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()): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index bb7b7d6e9..f8f92e0dc 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -32,6 +32,7 @@ from glyphsLib.writer import Writer from fontTools.misc.py23 import UnicodeIO from ufonormalizer import normalizeUFO +import defcon def write_to_lines(glyphs_object): @@ -146,6 +147,42 @@ def write_designspace_and_UFOs(designspace, path): 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=''): @@ -168,6 +205,7 @@ def clean_git_folder(): 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") @@ -175,6 +213,7 @@ def clean_git_folder(): 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") @@ -203,9 +242,11 @@ def assertDesignspaceRoundtrip(self, designspace): 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, From 8fcdc2ce1d8b4fe437412aa8389ef78c9ab4f106 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 20 Dec 2017 15:47:58 +0000 Subject: [PATCH 09/44] Add unit tests for all UFO info fields --- Lib/glyphsLib/builder/custom_params.py | 60 ++- Lib/glyphsLib/builder/font.py | 3 +- Lib/glyphsLib/builder/masters.py | 20 + Lib/glyphsLib/builder/names.py | 22 +- Lib/glyphsLib/classes.py | 6 +- tests/builder/builder_test.py | 213 ---------- tests/builder/custom_params_test.py | 246 ++++++++++++ tests/builder/fontinfo_test.py | 521 +++++++++++++++++++++++++ 8 files changed, 866 insertions(+), 225 deletions(-) create mode 100644 tests/builder/custom_params_test.py create mode 100644 tests/builder/fontinfo_test.py diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 26d3c712a..958ba430c 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -170,14 +170,14 @@ def __init__(self, glyphs_name, ufo_name=None, # - 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: + if ufo_value is None or ufo_value == []: 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: + if glyphs_value is None or glyphs_value == []: return ufo_value = self.value_to_ufo(glyphs_value) self._write_to_ufo(glyphs, ufo, ufo_value) @@ -271,6 +271,8 @@ def register(handler): 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', @@ -278,6 +280,42 @@ def register(handler): 'openTypeVheaCaretSlopeRise', 'openTypeHheaCaretOffset', 'openTypeVheaCaretOffset', + 'openTypeHeadLowestRecPPEM', + 'openTypeHeadFlags', + 'openTypeNameVersion', + 'openTypeNameUniqueID', + + # TODO: 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', ) for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: register(ParamHandler(name, name)) @@ -331,7 +369,8 @@ def to_ufo_gasp_table(value): def to_glyphs_gasp_table(value): return { - record['rangeMaxPPEM']: int_list_to_bin(record['rangeGaspBehavior']) + str(record['rangeMaxPPEM']): + int_list_to_bin(record['rangeGaspBehavior']) for record in value } @@ -386,6 +425,11 @@ def _write_to_glyphs(self, glyphs, value): 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), @@ -403,9 +447,13 @@ def to_glyphs(self, glyphs, ufo): def to_ufo(self, glyphs, ufo): for glyphs_name, value in self.flags: if glyphs.get_custom_value(glyphs_name): - if ufo.get_info_value('openTypeOS2Selection') is None: - ufo.set_info_value('openTypeOS2Selection', []) - ufo.get_info_value('openTypeOS2Selection').append(value) + 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()) diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index f6b8f67c1..ec919d6ad 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -19,7 +19,7 @@ import logging from .common import to_ufo_time, from_ufo_time -from .constants import GLYPHS_PREFIX +from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX logger = logging.getLogger(__name__) @@ -49,6 +49,7 @@ 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 index, master in enumerate(font.masters): diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index 12c519e3d..afcff4841 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -22,6 +22,9 @@ MASTER_ID_LIB_KEY = GLYPHS_PREFIX + 'fontMasterID' UFO_FILENAME_KEY = GLYPHLIB_PREFIX + 'ufoFilename' +UFO_YEAR_KEY = GLYPHLIB_PREFIX + 'ufoYear' +UFO_TRADEMARK_KEY = GLYPHLIB_PREFIX + 'ufoTrademark' +UFO_NOTE_KEY = GLYPHLIB_PREFIX + 'ufoNote' def to_ufo_master_attributes(self, ufo, master): @@ -40,6 +43,16 @@ def to_ufo_master_attributes(self, ufo, master): if italic_angle: ufo.info.italicAngle = italic_angle + year = master.userData[UFO_YEAR_KEY] + if year is not None: + ufo.info.year = year + trademark = master.userData[UFO_TRADEMARK_KEY] + if trademark is not None: + ufo.info.trademark = trademark + 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 @@ -100,6 +113,13 @@ def to_glyphs_master_attributes(self, ufo, master): 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.trademark is not None: + master.userData[UFO_TRADEMARK_KEY] = ufo.info.trademark + if ufo.info.note is not None: + master.userData[UFO_NOTE_KEY] = ufo.info.note + # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 source = _get_designspace_source_for_ufo(self, ufo) for axis in ['weight', 'width']: diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index 824c0579f..ca6f67c3f 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -105,5 +105,23 @@ def to_glyphs_family_names(self, ufo): def to_glyphs_master_names(self, ufo, master): - # ??? - master.name = ufo.info.styleName + # One way would be to split the `ufo.info.styleName` + # and find out for each part whether it is a width, weight or customName + + # Instead we shove all of it into custom, unless we can already build the + # stylename with the currently available info in the master. + # TODO: more testing of this + width = master.width + weight = master.weight + custom = master.customName + is_italic = bool(master.italicAngle) + + current_stylename = build_style_name( + width if width != 'Regular' else '', + weight if weight != 'Regular' else '', + custom, + is_italic + ) + + if current_stylename != ufo.info.styleName: + master.customName = ufo.info.styleName diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index a3b78ca1a..8304ff793 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -984,7 +984,7 @@ 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) @@ -1058,7 +1058,7 @@ class GSCustomParameter(GSBase): 'openTypeVheaVertTypoAscender', 'openTypeVheaVertTypoDescender', 'openTypeVheaVertTypoLineGap', 'postscriptBlueFuzz', 'postscriptBlueShift', - 'postscriptDefaultWidthX', 'postscriptSlantAngle', + 'postscriptDefaultWidthX', 'postscriptUnderlinePosition', 'postscriptUnderlineThickness', 'postscriptUniqueID', 'postscriptWindowsCharacterSet', 'shoulderHeight', @@ -1068,7 +1068,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', diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index 338ec8aad..70f148e60 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -23,13 +23,6 @@ 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 from defcon import Font from fontTools.misc.loggingTools import CapturingLogHandler @@ -42,8 +35,6 @@ from glyphsLib.builder import to_ufos from glyphsLib.builder.builders import UFOBuilder, GlyphsBuilder from glyphsLib.builder.paths import to_ufo_paths -from glyphsLib.builder.custom_params import (to_ufo_custom_params, - _set_default_params) from glyphsLib.builder.names import build_stylemap_names, build_style_name from glyphsLib.builder.filters import parse_glyphs_filter from glyphsLib.builder.constants import ( @@ -278,210 +269,6 @@ def test_linked_style_bold_italic(self): self.assertEqual("bold italic", map_style) -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: 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) - - 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' diff --git a/tests/builder/custom_params_test.py b/tests/builder/custom_params_test.py new file mode 100644 index 000000000..74e2733b7 --- /dev/null +++ b/tests/builder/custom_params_test.py @@ -0,0 +1,246 @@ +# 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: 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) + + diff --git a/tests/builder/fontinfo_test.py b/tests/builder/fontinfo_test.py new file mode 100644 index 000000000..a54944b72 --- /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), + ), + skip_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 From b869196c1a3bb08ad7ea848d5bfc6db63b7783b5 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Tue, 9 Jan 2018 12:20:19 +0000 Subject: [PATCH 10/44] Improve roundtrip of feature files --- Lib/glyphsLib/builder/features.py | 97 +++++++++--- Lib/glyphsLib/classes.py | 2 + tests/builder/features_test.py | 235 ++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 tests/builder/features_test.py diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index 57f5a3a35..fca6571dd 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -28,6 +28,9 @@ from .constants import GLYPHLIB_PREFIX, PUBLIC_PREFIX +ANONYMOUS_FEATURE_PREFIX_NAME = '' + + def autostr(automatic): return '# automatic\n' if automatic else '' @@ -35,10 +38,16 @@ def autostr(automatic): def to_ufo_features(self, 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) - for prefix in self.font.featurePrefixes) + 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: @@ -181,27 +190,25 @@ def _statement_text(self, statement): _, end_line, end_char = statement.end_location lines = self._lines[begin_line - 1:end_line] if lines: - lines[0] = lines[0][begin_char - 1:] + # 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(fake_location, "Sentinel")) self._build_end_locations_rec(self._doc) - - # TODO: (jany) add a test with a complex feature file (nested blocks) - if self._doc.statements: - end_location = (None, len(self._lines) + 1, - len(self._lines[-1]) + 1) - last_statement = self._doc.statements[-1] - while True: - last_statement.end_location = end_location - if (not hasattr(last_statement, 'statements') or - not last_statement.statements): - break - last_statement = last_statement.statements[-1] + # 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: @@ -220,6 +227,7 @@ def _build_end_locations_rec(self, block): 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] @@ -294,17 +302,33 @@ def to_glyphs(self, font): NOTES_RE = re.compile('^# notes:$') def _process_file(self): + unhandled_root_elements = [] while self.statements.has_next(): - if (not self._process_prefix() and - not self._process_glyph_class_definition() and - not self._process_feature_block() and - not self._process_gdef_table_block()): - # FIXME: (jany) Discard other root-level comments... bad? - # Maybe put them in anonymous featurePrefixes + 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? # TODO: (jany) Check the list of all possible blocks in ast and # handle them all (even if dummy implem) + 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() @@ -361,7 +385,32 @@ def _process_glyph_class_definition(self): self.statements.next() glyph_class = self.glyphs_module.GSClass() glyph_class.name = st.name - glyph_class.code = ' '.join(st.glyphSet()) + # 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 diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 8304ff793..996a0cdf6 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -2959,6 +2959,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 diff --git a/tests/builder/features_test.py b/tests/builder/features_test.py new file mode 100644 index 000000000..e025ff810 --- /dev/null +++ b/tests/builder/features_test.py @@ -0,0 +1,235 @@ +# 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 + + +# FIXME: (jany) fix in feaLib +@pytest.mark.xfail( + reason='feaLib does not parse correctly a file with only comments') +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 == ufo.features.text + 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 ]; + ''') + + +@pytest.mark.xfail(reason='Fealib will always resolve includes') +# FIXME: (jany) what to do? +# 1. Have an option in feaLib to NOT follow includes and have an AST element +# like "include statement". This is the easiest way to handle roundtrip +# because we can have a GSFeaturePrefix with the include statement in it. +# 2. Always enforce that includes must be resolvable, and dispatch their +# contents into GSFeaturePrefix, GSClass, GSFeature and so on. Very hard +# to roundtrip because we lose the original include information (or we +# need lots of bookkeeping) +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 == ufo.features.text + + 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() + + +# TODO: add test with different features in different UFOs +# Assumption: all UFOs must have the same feature files, otherwise warning +# Use the feature file from the first UFO From f17b1112c9ec5fbed66dfb5c692e19333df6a926 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Tue, 9 Jan 2018 18:14:55 +0000 Subject: [PATCH 11/44] Improve roundtrip for guidelines and others --- Lib/glyphsLib/builder/common.py | 10 +++++ Lib/glyphsLib/builder/glyph.py | 43 ++++++------------ Lib/glyphsLib/builder/guidelines.py | 31 +++++++++++-- Lib/glyphsLib/types.py | 3 ++ tests/builder/to_glyphs_test.py | 68 +++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 33 deletions(-) diff --git a/Lib/glyphsLib/builder/common.py b/Lib/glyphsLib/builder/common.py index 77644a972..9bb4aff10 100644 --- a/Lib/glyphsLib/builder/common.py +++ b/Lib/glyphsLib/builder/common.py @@ -16,6 +16,7 @@ unicode_literals) import datetime +from glyphsLib.types import parse_datetime UFO_FORMAT = '%Y/%m/%d %H:%M:%S' @@ -28,3 +29,12 @@ def to_ufo_time(datetime_obj): 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/glyph.py b/Lib/glyphsLib/builder/glyph.py index bb63ea208..153dab713 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -20,7 +20,7 @@ from defcon import Color import glyphsLib.glyphdata -from .common import to_ufo_time, from_ufo_time +from .common import to_ufo_time, from_ufo_time, from_loose_ufo_time from .constants import (GLYPHLIB_PREFIX, GLYPHS_COLORS, GLYPHS_PREFIX, PUBLIC_PREFIX) @@ -45,7 +45,6 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph): ufo_glyph.lib[GLYPHLIB_PREFIX + 'lastChange'] = to_ufo_time(last_change) 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): @@ -58,7 +57,7 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph): else: 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 + ufo_glyph.markColor = color_tuple export = glyph.export if not export: ufo_glyph.lib[GLYPHLIB_PREFIX + 'Export'] = export @@ -150,17 +149,11 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): glyph.note = note if GLYPHLIB_PREFIX + 'lastChange' in ufo_glyph.lib: last_change = ufo_glyph.lib[GLYPHLIB_PREFIX + 'lastChange'] - glyph.lastChange = from_ufo_time(last_change) + # 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: - if GLYPHLIB_PREFIX + 'ColorIndex' in ufo_glyph.lib: - color_index = ufo_glyph.lib[GLYPHLIB_PREFIX + 'ColorIndex'] - if ufo_glyph.markColor == GLYPHS_COLORS[color_index]: - # Still coherent - glyph.color = color_index - else: - glyph.color = _to_glyphs_color_index(self, ufo_glyph.markColor) - else: - glyph.color = _to_glyphs_color_index(self, 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' @@ -248,20 +241,12 @@ def to_ufo_glyph_background(self, glyph, layer): self.to_ufo_guidelines(new_glyph, background) -def _to_glyphs_color_index(self, color): +def _to_glyphs_color(self, color): # color is a defcon Color - index, _ = min( - enumerate(GLYPHS_COLORS), - key=lambda _, glyphs_color: _rgb_distance(color, Color(glyphs_color))) - return index - # TODO: (jany) remove color approximation, actually it's possible to store - # arbitrary colors in Glyphs - - -def _rgb_distance(c1, c2): - # https://en.wikipedia.org/wiki/Color_difference - rmean = float(c1.r+c2.r) / 2 - dr = c1.r - c2.r - dg = c1.g - c2.g - db = c1.b - c2.b - return (2 + rmean)*dr*dr + 4*dg*dg + (3 - rmean)*db*db + # Try to find a matching Glyphs color + for index, glyphs_color in enumerate(GLYPHS_COLORS): + if str(color) == glyphs_color: + return index + + # Otherwise, make a Glyphs-formatted color list + return [int(component * 255) for component in list(color)] diff --git a/Lib/glyphsLib/builder/guidelines.py b/Lib/glyphsLib/builder/guidelines.py index 77ba6e524..2e57377ad 100644 --- a/Lib/glyphsLib/builder/guidelines.py +++ b/Lib/glyphsLib/builder/guidelines.py @@ -15,8 +15,14 @@ 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]' @@ -30,10 +36,21 @@ def to_ufo_guidelines(self, ufo_obj, glyphs_obj): x, y = guideline.position angle = guideline.angle angle = (360 - angle) % 360 + new_guideline = {'x': x, 'y': y, '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 += LOCKED_NAME_SUFFIX - new_guideline = {'x': x, 'y': y, 'angle': angle} + name = (name or '') + LOCKED_NAME_SUFFIX if name: new_guideline['name'] = name new_guidelines.append(new_guideline) @@ -47,10 +64,16 @@ def to_glyphs_guidelines(self, ufo_obj, glyphs_obj): 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, guideline.y) - new_guideline.angle = (360 - guideline.angle) % 360 + 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 glyphs_obj.guides.append(new_guideline) diff --git a/Lib/glyphsLib/types.py b/Lib/glyphsLib/types.py index b11d5a205..5293d89c3 100644 --- a/Lib/glyphsLib/types.py +++ b/Lib/glyphsLib/types.py @@ -112,6 +112,9 @@ class Point(Vector(2)): def __init__(self, value=None, value2=None, rect=None): if value is not None and value2 is not None: 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 diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index ac05c8b21..a61196819 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -18,9 +18,11 @@ unicode_literals) import pytest +import datetime import defcon +from glyphsLib.builder.constants import GLYPHS_COLORS, GLYPHLIB_PREFIX from glyphsLib import to_glyphs, to_ufos # TODO: (jany) think hard about the ordering and RTL/LTR @@ -177,3 +179,69 @@ def test_groups(): ufo, = to_ufos(font) assert dict(ufo.groups) == groups_dict + + +def test_guidelines(): + ufo = defcon.Font() + # Complete guideline + ufo.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 + ufo.appendGuideline({}) + ufo.appendGuideline({'x': 10}) + ufo.appendGuideline({'y': 20}) + + font = to_glyphs([ufo]) + + assert len(font.masters[0].guides) == 4 + + # We only care about the data in the first guideline + # The others are here to prevent crashes when only half of the data is here + g = font.masters[0].guides[0] + + assert g.position.x == 10 + assert g.position.y == 20 + assert g.angle == 330 + assert g.name == "lc [1,0,0,1] [#lc1]" + + ufo, = to_ufos(font) + + g = ufo.guidelines[0] + + assert g.x == 10 + assert g.y == 20 + assert g.angle == 30 + assert g.name == 'lc' + assert g.color == '1,0,0,1' + assert g.identifier == 'lc1' + + +def test_glyph_color(): + ufo = defcon.Font() + a = ufo.newGlyph('a') + a.markColor = GLYPHS_COLORS[3] + b = ufo.newGlyph('b') + b.markColor = '1,0.5,0,1' + + font = to_glyphs([ufo]) + + assert font.glyphs['a'].color == 3 + assert font.glyphs['b'].color == [255, 127, 0, 255] + + ufo, = to_ufos(font) + + assert ufo['a'].markColor == GLYPHS_COLORS[3] + # FIXME: (jany) rounding errors + assert ufo['b'].markColor == '1,0.498,0,1' + + +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)) From f8372d08a92035a6f9aa9a2c5ce503c4270707a4 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 10 Jan 2018 11:55:00 +0000 Subject: [PATCH 12/44] Add default axis locations to Glyphs masters --- Lib/glyphsLib/builder/masters.py | 49 ++++++++++++++++++++++++++++---- tests/builder/to_glyphs_test.py | 39 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index afcff4841..3ec94e0ca 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -123,9 +123,12 @@ def to_glyphs_master_attributes(self, ufo, master): # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 source = _get_designspace_source_for_ufo(self, ufo) for axis in ['weight', 'width']: + attr = 'openTypeOS2%sClass' % axis.capitalize() + ufo_class = getattr(ufo.info, attr) # First, try the designspace try: # TODO: ??? name = source.lib[...] + # TODO: maybe handled by names.py? # setattr(master, axis, name) raise KeyError except KeyError: @@ -133,9 +136,8 @@ def to_glyphs_master_attributes(self, ufo, master): try: setattr(master, axis, ufo.lib[GLYPHS_PREFIX + axis]) except KeyError: - # FIXME: (jany) as last resort, use 400/700 as a location, - # from the weightClass/widthClass? - pass + if ufo_class: + setattr(master, axis, _class_to_name(axis, ufo_class)) value_key = axis + 'Value' # First, try the designspace @@ -147,9 +149,9 @@ def to_glyphs_master_attributes(self, ufo, master): try: setattr(master, value_key, ufo.lib[GLYPHS_PREFIX + value_key]) except KeyError: - # FIXME: (jany) as last resort, use 400/700 as a location, - # from the weightClass/widthClass? - pass + if ufo_class: + setattr(master, value_key, _class_to_value( + axis, ufo_class)) for number in ('', '1', '2', '3'): # For the custom locations, we need both the name and the value @@ -188,3 +190,38 @@ def _get_designspace_source_for_ufo(self, ufo): for source in self.designspace.sources: if source.font == ufo: return source + +# FIXME: (jany) this code/data must also be somewhere else, refactor +# From the spec: https://www.microsoft.com/typography/otspec/os2.htm#wtc +CLASSES_DICT = { + 'weight': { + 100: ('Thin', 100), + 200: ('Extra-light', 200), + 300: ('Light', 300), + 400: ('Regular', 400), + 500: ('Medium', 500), + 600: ('Semi-bold', 600), + 700: ('Bold', 700), + 800: ('Extra-bold', 800), + 900: ('Black', 900), + }, + 'width': { + 1: ('Ultra-condensed', 50), + 2: ('Extra-condensed', 62.5), + 3: ('Condensed', 75), + 4: ('Semi-condensed', 87.5), + 5: ('Medium', 100), + 6: ('Semi-expanded', 112.5), + 7: ('Expanded', 125), + 8: ('Extra-expanded', 150), + 9: ('Ultra-expanded', 200), + } +} + + +def _class_to_name(axis, ufo_class): + return CLASSES_DICT[axis][ufo_class][0] + + +def _class_to_value(axis, ufo_class): + return CLASSES_DICT[axis][ufo_class][1] diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index a61196819..e46d6abc6 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -245,3 +245,42 @@ def test_bad_ufo_date_format_in_glyph_lib(): 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 From 53433ff42166aa870aead12225865f8cb238daa6 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 10 Jan 2018 17:56:01 +0000 Subject: [PATCH 13/44] Fix more small roundtrip problems --- Lib/glyphsLib/builder/builders.py | 7 +- Lib/glyphsLib/builder/glyph.py | 6 +- Lib/glyphsLib/builder/guidelines.py | 22 +++- tests/builder/to_glyphs_test.py | 183 ++++++++++++++++++++++++---- 4 files changed, 187 insertions(+), 31 deletions(-) diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 8e65a78e4..e75482d2c 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -326,7 +326,12 @@ def __init__(self, # FIXME: (jany) Do something better for the InMemory stuff # Is it an in-memory source descriptor? if not hasattr(source, 'font'): - source.font = designspace.fontClass(source.path) + if source.path: + source.font = designspace.fontClass(source.path) + else: + dirname = os.path.dirname(designspace.path) + ufo_path = os.path.join(dirname, source.filename) + source.font = designspace.fontClass(ufo_path) self.ufos.append(source.font) elif ufos: self.designspace = self._fake_designspace(ufos) diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index 153dab713..f6c47d2f3 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -232,7 +232,9 @@ def to_ufo_glyph_background(self, glyph, layer): else: layer = font.layers[layer_name] new_glyph = layer.newGlyph(glyph.name) - new_glyph.width = glyph.width + + # FIXME: (jany) find out more about Glyphs' background width handling + new_glyph.width = background.width self.to_ufo_background_image(new_glyph, background) self.to_ufo_paths(new_glyph, background) @@ -249,4 +251,4 @@ def _to_glyphs_color(self, color): return index # Otherwise, make a Glyphs-formatted color list - return [int(component * 255) for component in list(color)] + return [round(component * 255) for component in list(color)] diff --git a/Lib/glyphsLib/builder/guidelines.py b/Lib/glyphsLib/builder/guidelines.py index 2e57377ad..fa09fea22 100644 --- a/Lib/glyphsLib/builder/guidelines.py +++ b/Lib/glyphsLib/builder/guidelines.py @@ -33,10 +33,18 @@ 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 angle = (360 - angle) % 360 - new_guideline = {'x': x, 'y': y, 'angle': angle} + 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 @@ -76,4 +84,16 @@ def to_glyphs_guidelines(self, ufo_obj, glyphs_obj): 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/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index e46d6abc6..83943fbd5 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -19,11 +19,15 @@ import pytest import datetime +import os import defcon from glyphsLib.builder.constants import GLYPHS_COLORS, GLYPHLIB_PREFIX -from glyphsLib import to_glyphs, to_ufos +from glyphsLib import to_glyphs, to_ufos, to_designspace + +# FIXME: (jany) should come from fonttools +from glyphsLib.designSpaceDocument import DesignSpaceDocument # TODO: (jany) think hard about the ordering and RTL/LTR # TODO: (jany) make one generic test with data using pytest @@ -183,37 +187,58 @@ def test_groups(): def test_guidelines(): ufo = defcon.Font() - # Complete guideline - ufo.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 - ufo.appendGuideline({}) - ufo.appendGuideline({'x': 10}) - ufo.appendGuideline({'y': 20}) + 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]) - assert len(font.masters[0].guides) == 4 + for gobj in [font.masters[0], font.glyphs['a'].layers[0]]: + assert len(gobj.guides) == 4 + + angled, vertical, horizontal, empty = gobj.guides - # We only care about the data in the first guideline - # The others are here to prevent crashes when only half of the data is here - g = font.masters[0].guides[0] + assert angled.position.x == 10 + assert angled.position.y == 20 + assert angled.angle == 330 + assert angled.name == "lc [1,0,0,1] [#lc1]" - assert g.position.x == 10 - assert g.position.y == 20 - assert g.angle == 330 - assert g.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) - g = ufo.guidelines[0] + 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 g.x == 10 - assert g.y == 20 - assert g.angle == 30 - assert g.name == 'lc' - assert g.color == '1,0,0,1' - assert g.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(): @@ -221,18 +246,17 @@ def test_glyph_color(): a = ufo.newGlyph('a') a.markColor = GLYPHS_COLORS[3] b = ufo.newGlyph('b') - b.markColor = '1,0.5,0,1' + 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 == [255, 127, 0, 255] + assert font.glyphs['b'].color == [4, 128, 0, 255] ufo, = to_ufos(font) assert ufo['a'].markColor == GLYPHS_COLORS[3] - # FIXME: (jany) rounding errors - assert ufo['b'].markColor == '1,0.498,0,1' + assert ufo['b'].markColor == b.markColor def test_bad_ufo_date_format_in_glyph_lib(): @@ -284,3 +308,108 @@ def test_have_default_interpolation_values(): 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(): + 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 + + font = to_glyphs([ufo]) + + ufo, = to_ufos(font) + + assert ufo['a'].width == 100 + assert ufo.layers['public.background']['a'].width == 0 # 0 is the default + assert ufo['b'].width == 200 + assert ufo.layers['public.background']['b'].width == 300 + + +def test_dont_zero_width_of_nonspacing_marks_if_it_was_not_zero(): + # TODO + pass From 4228ca00dd36479d864d167088ccd063de532292 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 11 Jan 2018 15:08:43 +0000 Subject: [PATCH 14/44] Fix the roundtrip of advance width of background --- Lib/glyphsLib/builder/glyph.py | 21 +++++++++++++++------ Lib/glyphsLib/classes.py | 14 +++++++++++++- tests/builder/to_glyphs_test.py | 28 ++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index f6c47d2f3..12bc5167c 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -26,6 +26,7 @@ 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): @@ -193,7 +194,14 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): sub_category = glyphinfo.subCategory # load width before background, which is loaded with lib data - layer.width = ufo_glyph.width + 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: @@ -228,13 +236,14 @@ def to_ufo_glyph_background(self, glyph, layer): layer_name = 'public.background' font = glyph.font if layer_name not in font.layers: - layer = font.newLayer(layer_name) + ufo_layer = font.newLayer(layer_name) else: - layer = font.layers[layer_name] - new_glyph = layer.newGlyph(glyph.name) + ufo_layer = font.layers[layer_name] + new_glyph = ufo_layer.newGlyph(glyph.name) - # FIXME: (jany) find out more about Glyphs' background width handling - new_glyph.width = background.width + 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) diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 996a0cdf6..2b36a8d52 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -2465,7 +2465,6 @@ class GSLayer(GSBase): } _defaultsForName = { "width": 600.0, - "weight": 600.0, "leftMetricsKey": None, "rightMetricsKey": None, "widthMetricsKey": None, @@ -2693,6 +2692,19 @@ def background(self): 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 diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 83943fbd5..a2b7e6797 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -25,6 +25,7 @@ from glyphsLib.builder.constants import GLYPHS_COLORS, GLYPHLIB_PREFIX from glyphsLib import to_glyphs, to_ufos, to_designspace +from glyphsLib import classes # FIXME: (jany) should come from fonttools from glyphsLib.designSpaceDocument import DesignSpaceDocument @@ -387,7 +388,7 @@ def test_ufo_filename_is_kept_the_same(tmpdir): assert designspace.sources[1].filename == 'subdir/bold.ufo' -def test_dont_copy_advance_to_the_background_unless_it_was_there(): +def test_dont_copy_advance_to_the_background_unless_it_was_there(tmpdir): ufo = defcon.Font() bg = ufo.newLayer('public.background') @@ -400,14 +401,25 @@ def test_dont_copy_advance_to_the_background_unless_it_was_there(): bg_b = bg.newGlyph('b') bg_b.width = 300 - font = to_glyphs([ufo]) - - ufo, = to_ufos(font) + fg_c = ufo.newGlyph('c') + fg_c.width = 400 + bg_c = bg.newGlyph('c') + bg_c.width = 400 - assert ufo['a'].width == 100 - assert ufo.layers['public.background']['a'].width == 0 # 0 is the default - assert ufo['b'].width == 200 - assert ufo.layers['public.background']['b'].width == 300 + 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(): From 5d094169496c92fc593238cdfce0f8ff13adb942 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 11 Jan 2018 17:58:34 +0000 Subject: [PATCH 15/44] Handle double unicodes, see #216 --- Lib/glyphsLib/builder/glyph.py | 13 +++---------- Lib/glyphsLib/classes.py | 24 +++++++++++++++++++++--- Lib/glyphsLib/types.py | 19 +++++++++++++++++++ Lib/glyphsLib/writer.py | 10 +++++----- tests/builder/to_glyphs_test.py | 19 +++++++++++++++++++ tests/writer_test.py | 5 +++++ 6 files changed, 72 insertions(+), 18 deletions(-) diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index 12bc5167c..d60607643 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -33,11 +33,7 @@ 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.unicode - if uval is not None: - ufo_glyph.unicode = int(uval, 16) - # FIXME: (jany) handle several unicodes - # https://github.com/googlei18n/glyphsLib/issues/216 + ufo_glyph.unicodes = [int(uval, 16) for uval in glyph.unicodes] note = glyph.note if note is not None: ufo_glyph.note = note @@ -140,11 +136,8 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): # FIXME: (jany) ordering? self.font.glyphs.append(glyph) - uval = ufo_glyph.unicode - if uval is not None: - glyph.unicode = '{:04X}'.format(uval) - # FIXME: (jany) handle several unicodes - # https://github.com/googlei18n/glyphsLib/issues/216 + 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 diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 2b36a8d52..e93582b98 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -27,7 +27,7 @@ import glyphsLib from glyphsLib.types import ( ValueType, Transform, Point, Rect, Size, parse_datetime, parse_color, - floatToString, readIntlist, writeIntlist) + floatToString, readIntlist, writeIntlist, UnicodesList) from glyphsLib.parser import Parser from glyphsLib.writer import Writer, escape_string from collections import OrderedDict @@ -2731,12 +2731,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", } @@ -2755,7 +2756,6 @@ class GSGlyph(GSBase): "subCategory": None, "userData": None, "widthMetricsKey": None, - "unicode": None, } _keyOrder = ( "color", @@ -2847,6 +2847,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 = { diff --git a/Lib/glyphsLib/types.py b/Lib/glyphsLib/types.py index 5293d89c3..f6ed26954 100644 --- a/Lib/glyphsLib/types.py +++ b/Lib/glyphsLib/types.py @@ -353,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/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/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index a2b7e6797..ee2256009 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -425,3 +425,22 @@ def test_dont_copy_advance_to_the_background_unless_it_was_there(tmpdir): 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] diff --git a/tests/writer_test.py b/tests/writer_test.py index 4774a235b..f9ec57070 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -685,6 +685,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 From 9e618d2e936869971c4f3a3725d4eef8cbe9fda6 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 11 Jan 2018 18:29:53 +0000 Subject: [PATCH 16/44] Handle Mac FOND fontinfo data --- Lib/glyphsLib/builder/custom_params.py | 3 +++ tests/builder/fontinfo_test.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 958ba430c..047650b7c 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -316,6 +316,9 @@ def register(handler): 'postscriptWeightName', 'postscriptDefaultCharacter', 'postscriptWindowsCharacterSet', + + 'macintoshFONDFamilyID', + 'macintoshFONDName', ) for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: register(ParamHandler(name, name)) diff --git a/tests/builder/fontinfo_test.py b/tests/builder/fontinfo_test.py index a54944b72..9d5a96b99 100644 --- a/tests/builder/fontinfo_test.py +++ b/tests/builder/fontinfo_test.py @@ -441,7 +441,7 @@ def __init__(self, name, test_value): # values are defined below. Field('postscriptWindowsCharacterSet', 4), ), - skip_section( + section( 'Macintosh FOND Resource Data', # macintoshFONDFamilyID integer Family ID number. Corresponds to the # ffFamID in the FOND resource. From 91a2174ce29f8272e5b55e1cee0d3ce74bbea95f Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 22 Jan 2018 14:29:48 +0000 Subject: [PATCH 17/44] Fix problems with feaLib, TO REVERT ONCE IN UPSTREAM --- Lib/glyphsLib/builder/features.py | 114 +++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index fca6571dd..73b12d9d6 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -19,9 +19,13 @@ from textwrap import dedent from fontTools.misc.py23 import round, unicode -from fontTools.feaLib import parser, ast from fontTools.misc.py23 import StringIO +from fontTools.feaLib.lexer import Lexer +from fontTools.feaLib.parser import Parser, SymbolTable +from fontTools.feaLib.error import FeatureLibError +from fontTools.feaLib import parser, ast + import re import glyphsLib @@ -169,11 +173,117 @@ def to_glyphs_features(self, ufo): processor.to_glyphs(self.font) +# TODO: (jany) backport to feaLib +class IncludeStatement(ast.Statement): + def __init__(self, location, filename): + super(IncludeStatement, self).__init__(location) + self.filename = filename + + def build(self): + # TODO: (jany) check that raising makes sense here + # TODO: (jany) use the correct exception class + raise FeatureLibError( + "It does not make sense to build an include statement, " + "use the including parser for building", + self.location) + + def asFea(self, indent=""): + return indent + "include(%s);" % self.filename + + +# TODO: (jany) backport to feaLib +class NonIncludingParser(Parser): + def __init__(self, featurefile, glyphMap): + self.glyphMap_ = glyphMap + self.doc_ = self.ast.FeatureFile() + self.anchors_ = SymbolTable() + self.glyphclasses_ = SymbolTable() + self.lookups_ = SymbolTable() + self.valuerecords_ = SymbolTable() + self.symbol_tables_ = { + self.anchors_, self.valuerecords_ + } + self.next_token_type_, self.next_token_ = (None, None) + self.cur_comments_ = [] + self.next_token_location_ = None + # Diff: simple Lexer + filename = None + if hasattr(featurefile, "read"): + fileobj, closing = featurefile, False + else: + filename, closing = featurefile, True + # try: + fileobj = open(filename, "r", encoding="utf-8") + # except IOError as err: + # raise feaLib.FeatureLibError(str(err), location) + data = fileobj.read() + if not filename: + filename = fileobj.name if hasattr(fileobj, "name") else "" + if closing: + fileobj.close() + self.lexer_ = Lexer(data, filename) + self.advance_lexer_(comments=True) + + def parse(self): + statements = self.doc_.statements + # Diff: or self.cur_comments_ + while self.next_token_type_ is not None or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + statements.append(self.ast.Comment(self.cur_token_location_, self.cur_token_)) + elif self.cur_token_type_ is Lexer.GLYPHCLASS: + statements.append(self.parse_glyphclass_definition_()) + # Diff: return "include" statements as-is + elif self.is_cur_keyword_("include"): + statements.append(self.parse_include_()) + elif self.is_cur_keyword_(("anon", "anonymous")): + statements.append(self.parse_anonymous_()) + elif self.is_cur_keyword_("anchorDef"): + statements.append(self.parse_anchordef_()) + elif self.is_cur_keyword_("languagesystem"): + statements.append(self.parse_languagesystem_()) + elif self.is_cur_keyword_("lookup"): + statements.append(self.parse_lookup_(vertical=False)) + elif self.is_cur_keyword_("markClass"): + statements.append(self.parse_markClass_()) + elif self.is_cur_keyword_("feature"): + statements.append(self.parse_feature_block_()) + elif self.is_cur_keyword_("table"): + statements.append(self.parse_table_()) + elif self.is_cur_keyword_("valueRecordDef"): + statements.append( + self.parse_valuerecord_definition_(vertical=False)) + elif self.cur_token_type_ is Lexer.NAME and self.cur_token_ in self.extensions: + statements.append(self.extensions[self.cur_token_](self)) + elif self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == ";": + continue + else: + raise FeatureLibError( + "Expected feature, languagesystem, lookup, markClass, " + "table, or glyph class definition, got {} \"{}\"".format(self.cur_token_type_, self.cur_token_), + self.cur_token_location_) + return self.doc_ + + def parse_include_(self): + assert self.cur_token_ == "include" + location = self.cur_token_location_ + filename = self.expect_filename_() + # self.expect_symbol_(";") + return IncludeStatement(location, filename) + + def expect_filename_(self): + self.advance_lexer_() + if self.cur_token_type_ is not Lexer.FILENAME: + raise FeatureLibError("Expected file name", + self.cur_token_location_) + return self.cur_token_ + + 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) + parser_ = NonIncludingParser(feature_file, glyph_set) self._doc = parser_.parse() self.statements = self._doc.statements self._lines = text.splitlines(True) # keepends=True From 9347544d71a129294dda4128d0a22e49ab48a346 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 22 Jan 2018 14:30:17 +0000 Subject: [PATCH 18/44] Remove xfail on feature tests about includes and comments --- tests/builder/features_test.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/builder/features_test.py b/tests/builder/features_test.py index e025ff810..c0ab847d2 100644 --- a/tests/builder/features_test.py +++ b/tests/builder/features_test.py @@ -51,9 +51,6 @@ def test_blank(tmpdir): assert not rtufo.features.text -# FIXME: (jany) fix in feaLib -@pytest.mark.xfail( - reason='feaLib does not parse correctly a file with only comments') def test_comment(tmpdir): ufo = defcon.Font() ufo.features.text = dedent('''\ @@ -66,7 +63,7 @@ def test_comment(tmpdir): assert not font.features assert len(font.featurePrefixes) == 1 fp = font.featurePrefixes[0] - assert fp.code == ufo.features.text + assert fp.code.strip() == ufo.features.text.strip() assert not fp.automatic assert rtufo.features.text == ufo.features.text @@ -139,15 +136,6 @@ def test_class_synonym(tmpdir): ''') -@pytest.mark.xfail(reason='Fealib will always resolve includes') -# FIXME: (jany) what to do? -# 1. Have an option in feaLib to NOT follow includes and have an AST element -# like "include statement". This is the easiest way to handle roundtrip -# because we can have a GSFeaturePrefix with the include statement in it. -# 2. Always enforce that includes must be resolvable, and dispatch their -# contents into GSFeaturePrefix, GSClass, GSFeature and so on. Very hard -# to roundtrip because we lose the original include information (or we -# need lots of bookkeeping) def test_include(tmpdir): ufo = defcon.Font() ufo.features.text = dedent('''\ @@ -159,7 +147,21 @@ def test_include(tmpdir): font, rtufo = roundtrip(ufo, tmpdir) assert len(font.featurePrefixes) == 1 - assert font.featurePrefixes[0].code == ufo.features.text + 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 From c3e8f776872eb2b5be0042960bbc231b8ce2c38f Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 25 Jan 2018 18:34:53 +0000 Subject: [PATCH 19/44] Fix open contours UFO -> .glyphs --- Lib/glyphsLib/builder/paths.py | 4 +++- Lib/glyphsLib/classes.py | 1 - tests/builder/to_glyphs_test.py | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Lib/glyphsLib/builder/paths.py b/Lib/glyphsLib/builder/paths.py index 4b93b9e09..9985fc6e5 100644 --- a/Lib/glyphsLib/builder/paths.py +++ b/Lib/glyphsLib/builder/paths.py @@ -56,8 +56,8 @@ def to_glyphs_paths(self, ufo_glyph, layer): node.smooth = point.smooth node.name = point.name path.nodes.append(node) + path.closed = not contour.open if not contour.open: - path.closed = True path.nodes.append(path.nodes.pop(0)) layer.paths.append(path) @@ -74,4 +74,6 @@ def _to_ufo_node_type(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/classes.py b/Lib/glyphsLib/classes.py index e93582b98..c8d4d8f48 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -1496,7 +1496,6 @@ class GSPath(GSBase): def __init__(self): super(GSPath, self).__init__() - self._closed = True self.nodes = [] @property diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index ee2256009..0fe9512fc 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -444,3 +444,24 @@ def test_double_unicodes(tmpdir): 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]]) From 5f984a26f7a6beb506b5886b700a3c3bbaa14ba4 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Fri, 26 Jan 2018 14:32:07 +0000 Subject: [PATCH 20/44] Improve designspace generation and roundtrip --- Lib/glyphsLib/builder/__init__.py | 2 + Lib/glyphsLib/builder/axes.py | 403 +++++++++++++ Lib/glyphsLib/builder/builders.py | 181 +++--- Lib/glyphsLib/builder/font.py | 27 +- Lib/glyphsLib/builder/groups.py | 14 +- Lib/glyphsLib/builder/instances.py | 405 ++++--------- Lib/glyphsLib/builder/kerning.py | 6 +- Lib/glyphsLib/builder/masters.py | 115 +--- Lib/glyphsLib/builder/names.py | 20 +- Lib/glyphsLib/builder/sources.py | 79 +++ Lib/glyphsLib/builder/user_data.py | 4 +- Lib/glyphsLib/classes.py | 117 ++-- Lib/glyphsLib/designSpaceDocument.py | 63 +- Lib/glyphsLib/util.py | 4 +- tests/builder/builder_test.py | 2 +- tests/builder/interpolation_test.py | 560 ++++++++++++++++++ tests/builder/to_glyphs_test.py | 3 +- tests/classes_test.py | 26 +- tests/data/DesignspaceTestBasic.designspace | 30 +- .../DesignspaceTestFamilyName.designspace | 18 +- .../data/DesignspaceTestFileName.designspace | 18 +- .../data/DesignspaceTestInactive.designspace | 12 +- .../DesignspaceTestInstanceOrder.designspace | 24 +- tests/data/DesignspaceTestTwoAxes.designspace | 80 +-- tests/interpolation_test.py | 408 ------------- tests/writer_test.py | 2 +- 26 files changed, 1567 insertions(+), 1056 deletions(-) create mode 100644 Lib/glyphsLib/builder/axes.py create mode 100644 Lib/glyphsLib/builder/sources.py create mode 100644 tests/builder/interpolation_test.py delete mode 100644 tests/interpolation_test.py diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index e27ca485e..fb0b10867 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -58,6 +58,7 @@ def to_ufos(font, def to_designspace(font, family_name=None, + instance_dir=None, propagate_anchors=True, ufo_module=defcon, minimize_glyphs_diffs=False): @@ -76,6 +77,7 @@ def to_designspace(font, font, ufo_module=ufo_module, family_name=family_name, + instance_dir=instance_dir, propagate_anchors=propagate_anchors, use_designspace=True, minimize_glyphs_diffs=minimize_glyphs_diffs) diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py new file mode 100644 index 000000000..ac59ce4c6 --- /dev/null +++ b/Lib/glyphsLib/builder/axes.py @@ -0,0 +1,403 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import (print_function, division, absolute_import, + unicode_literals) + +from collections import OrderedDict + +from glyphsLib import classes +from glyphsLib.classes import WEIGHT_CODES, WIDTH_CODES +from .constants import (GLYPHS_PREFIX, GLYPHLIB_PREFIX, + FONT_CUSTOM_PARAM_PREFIX, MASTER_CUSTOM_PARAM_PREFIX) + +# This is a key into GSFont.userData to store axes defined in the designspace +AXES_KEY = GLYPHLIB_PREFIX + 'axes' + +# From the spec: https://www.microsoft.com/typography/otspec/os2.htm#wtc +CLASSES_DICT = { + 'wght': { + 100: ('Thin', 100), + 200: ('Extra-light', 200), + 300: ('Light', 300), + 400: ('Regular', 400), + 500: ('Medium', 500), + 600: ('Semi-bold', 600), + 700: ('Bold', 700), + 800: ('Extra-bold', 800), + 900: ('Black', 900), + }, + 'wdth': { + 1: ('Ultra-condensed', 50), + 2: ('Extra-condensed', 62.5), + 3: ('Condensed', 75), + 4: ('Semi-condensed', 87.5), + 5: ('Medium', 100), + 6: ('Semi-expanded', 112.5), + 7: ('Expanded', 125), + 8: ('Extra-expanded', 150), + 9: ('Ultra-expanded', 200), + } +} + + +def class_to_name(axis, ufo_class): + """ + >>> class_to_name('wdth', 7) + 'Expanded' + """ + return CLASSES_DICT[axis][int(ufo_class)][0] + + +def class_to_value(axis, ufo_class): + """ + >>> class_to_value('wdth', 7) + 125 + """ + if axis == 'wght': + # 600.0 => 600, 250 => 250 + return int(ufo_class) + return CLASSES_DICT[axis][int(ufo_class)][1] + + +def user_loc_code_to_value(axis_tag, user_loc): + """ Go from Glyphs UI strings to user space location. + + >>> user_loc_code_to_value('wght', 'ExtraLight') + 250 + >>> user_loc_code_to_value('wdth', 'SemiCondensed') + 87.5 + """ + if axis_tag == 'wght': + return class_to_value('wght', WEIGHT_CODES.get(user_loc, user_loc)) + if axis_tag == 'wdth': + return class_to_value('wdth', WIDTH_CODES.get(user_loc, user_loc)) + + # Currently this function should only be called with a width or weight + raise NotImplementedError + + +def user_loc_value_to_class(axis_tag, user_loc): + """ + >>> user_loc_value_to_class('wdth', 62) + 2 + """ + if axis_tag == 'wght': + return int(user_loc) + return min(sorted(CLASSES_DICT[axis_tag].items()), + key=lambda item: abs(item[1][1] - user_loc))[0] + + +def user_loc_value_to_code(axis_tag, user_loc): + """ + >>> user_loc_value_to_code('wdth', 150) + 'Extra Expanded' + """ + codes = {} + if axis_tag == 'wght': + codes = WEIGHT_CODES + elif axis_tag == 'wdth': + codes = WIDTH_CODES + else: + raise NotImplementedError + class_ = user_loc_value_to_class(axis_tag, user_loc) + return min(sorted((code, class_) for code, class_ in codes.items() + if code is not None), + key=lambda item: abs(item[1] - class_))[0] + + +def to_designspace_axes(self): + if not self.font.masters: + return + regular_master = get_regular_master(self.font) + assert isinstance(regular_master, classes.GSFontMaster) + + for axis_def in get_axis_definitions(self.font): + axis = self.designspace.newAxisDescriptor() + axis.tag = axis_def.tag + axis.name = axis_def.name + + regularDesignLoc = axis_def.get_design_loc(regular_master) + regularUserLoc = axis_def.get_user_loc(regular_master) + + axis.labelNames = {"en": axis_def.name} + instance_mapping = [] + for instance in self.font.instances: + if is_instance_active(instance) or self.minimize_glyphs_diffs: + designLoc = axis_def.get_design_loc(instance) + userLoc = axis_def.get_user_loc(instance) + instance_mapping.append((userLoc, designLoc)) + + # FIXME: (jany) why the next two lines? + if designLoc == regularDesignLoc: + regularUserLoc = userLoc + instance_mapping = sorted(set(instance_mapping)) # avoid duplicates + + master_mapping = [] + for master in self.font.masters: + designLoc = axis_def.get_design_loc(master) + # FIXME: (jany) in latest Glyphs (1113) masters don't have + # a user loc + userLoc = axis_def.get_user_loc(master) + master_mapping.append((userLoc, designLoc)) + master_mapping = sorted(set(master_mapping)) + + minimum = maximum = default = axis_def.default_user_loc + # Prefer the instance-based mapping + mapping = instance_mapping or master_mapping + if mapping: + minimum = min([userLoc for userLoc, _ in mapping]) + maximum = max([userLoc for userLoc, _ in mapping]) + default = min(maximum, max(minimum, regularUserLoc)) # clamp + + if (minimum < maximum or minimum != axis_def.default_user_loc or + len(instance_mapping) > 1 or len(master_mapping) > 1): + axis.map = mapping + axis.minimum = minimum + axis.maximum = maximum + axis.default = default + self.designspace.addAxis(axis) + + +def to_glyphs_axes(self): + weight = None + width = None + customs = [] + for axis in self.designspace.axes: + if axis.tag == 'wght': + weight = axis + elif axis.tag == 'wdth': + width = axis + else: + customs.append(axis) + + axes_parameter = [] + if weight is not None: + axes_parameter.append({'Name': weight.name or 'Weight', 'Tag': 'wght'}) + # TODO: (jany) store other data about this axis? + elif width is not None or customs: + # Add a dumb weight axis to not mess up the indices + # FIXME: (jany) I inferred this requirement from the code in + # https://github.com/googlei18n/glyphsLib/pull/306 + # which seems to suggest that the first value is always weight and + # the second always width + axes_parameter.append({'Name': 'Weight', 'Tag': 'wght'}) + + if width is not None: + axes_parameter.append({'Name': width.name or 'Width', 'Tag': 'wdth'}) + # TODO: (jany) store other data about this axis? + elif customs: + # Add a dumb weight axis to not mess up the indices + # FIXME: (jany) I inferred this requirement from the code in + # https://github.com/googlei18n/glyphsLib/pull/306 + # which seems to suggest that the first value is always weight and + # the second always width + axes_parameter.append({'Name': 'Width', 'Tag': 'wdth'}) + + for custom in customs: + axes_parameter.append({ + 'Name': custom.name, + 'Tag': custom.tag, + }) + # TODO: (jany) store other data about this axis? + + if axes_parameter and not _is_subset_of_default_axes(axes_parameter): + self.font.customParameters['Axes'] = axes_parameter + + if self.minimize_ufo_diffs: + # TODO: (jany) later, when Glyphs can manage general designspace axes + # self.font.userData[AXES_KEY] = [ + # dict( + # tag=axis.tag, + # name=axis.name, + # minimum=axis.minimum, + # maximum=axis.maximum, + # default=axis.default, + # hidden=axis.hidden, + # labelNames=axis.labelNames, + # ) + # for axis in self.designspace.axes + # ] + pass + + +class AxisDefinition(object): + def __init__(self, tag, name, design_loc_key, default_design_loc=0.0, + user_loc_key=None, user_loc_param=None, default_user_loc=0.0): + self.tag = tag + self.name = name + self.design_loc_key = design_loc_key + self.default_design_loc = default_design_loc + self.user_loc_key = user_loc_key + self.user_loc_param = user_loc_param + self.default_user_loc = default_user_loc + + def get_design_loc(self, master_or_instance): + return getattr(master_or_instance, self.design_loc_key) + + def set_design_loc(self, master_or_instance, value): + setattr(master_or_instance, self.design_loc_key, value) + + def get_user_loc(self, master_or_instance): + if self.tag == 'wdth': + # FIXME: (jany) existing test "DesignspaceTestTwoAxes.designspace" + # suggests that the user location is the same as the design loc + # for the width only + return self.get_design_loc(master_or_instance) + + user_loc = self.default_user_loc + if self.user_loc_key is not None: + user_loc = getattr(master_or_instance, self.user_loc_key) + user_loc = user_loc_code_to_value(self.tag, user_loc) + # The custom param takes over the key if it exists + # e.g. for weight: + # key = "weight" -> "Bold" -> 700 + # but param = "weightClass" -> 600 => 600 wins + if self.user_loc_param is not None: + class_ = master_or_instance.customParameters[self.user_loc_param] + if class_ is not None: + user_loc = class_to_value(self.tag, class_) + return user_loc + + def set_user_loc(self, master_or_instance, value): + # Try to set the key if possible, i.e. if there is a key, and + # if there exists a code that can represent the given value, e.g. + # for "weight": 600 can be represented by SemiBold so we use that, + # but for 550 there is no code so we will have to set the custom + # parameter as well. + code = user_loc_value_to_code(self.tag, value) + value_for_code = user_loc_code_to_value(self.tag, code) + if self.user_loc_key is not None: + setattr(master_or_instance, self.user_loc_key, code) + if self.user_loc_param is not None and value != value_for_code: + try: + class_ = user_loc_value_to_class(self.tag, value) + master_or_instance.customParameters[self.user_loc_param] = class_ + except: + pass + + def set_user_loc_code(self, master_or_instance, code): + # The previous method `set_user_loc` will not roundtrip every + # time, for example for value = 600, both "DemiBold" and "SemiBold" + # would work, so we provide this other method to set a specific code. + if self.user_loc_key is not None: + setattr(master_or_instance, self.user_loc_key, code) + + +DEFAULT_AXES_DEFS = ( + AxisDefinition('wght', 'Weight', 'weightValue', 100.0, + 'weight', 'weightClass', 400.0), + AxisDefinition('wdth', 'Width', 'widthValue', 100.0, + 'width', 'widthClass', 100.0), + AxisDefinition('XXXX', 'Custom', 'customValue', 0.0, None, None, 0.0), +) + + +# Adapted from PR https://github.com/googlei18n/glyphsLib/pull/306 +def get_axis_definitions(font): + axesParameter = font.customParameters["Axes"] + if axesParameter is None: + return DEFAULT_AXES_DEFS + + axesDef = [] + designLocKeys = ('weightValue', 'widthValue', 'customValue', + 'customValue1', 'customValue2', 'customValue3') + defaultDesignLocs = (100.0, 100.0, 0.0, 0.0, 0.0, 0.0) + userLocKeys = ('weight', 'width', None, None, None, None) + userLocParams = ('weightClass', 'widthClass', None, None, None, None) + defaultUserLocs = (400.0, 100.0, 0.0, 0.0, 0.0, 0.0) + for idx, axis in enumerate(axesParameter): + axesDef.append(AxisDefinition( + axis.get("Tag", "XXX%d" % idx if idx > 0 else "XXXX"), + axis["Name"], designLocKeys[idx], defaultDesignLocs[idx], + userLocKeys[idx], userLocParams[idx], defaultUserLocs[idx])) + return axesDef + + +def _is_subset_of_default_axes(axes_parameter): + if len(axes_parameter) > 3: + return False + for axis, axis_def in zip(axes_parameter, DEFAULT_AXES_DEFS): + if set(axis.keys()) != {'Name', 'Tag'}: + return False + if axis['Name'] != axis_def.name: + return False + if axis['Tag'] != axis_def.tag: + return False + return True + + +def get_regular_master(font): + """Find the "regular" master among the GSFontMasters. + + Tries to find the master with the passed 'regularName'. + If there is no such master or if regularName is None, + tries to find a base style shared between all masters + (defaulting to "Regular"), and then tries to find a master + with that style name. If there is no master with that name, + returns the first master in the list. + """ + if not font.masters: + return None + regular_name = font.customParameters['Variation Font Origin'] + if regular_name is not None: + for master in font.masters: + if master.name == regular_name: + return master + base_style = find_base_style(font.masters) + if not base_style: + base_style = 'Regular' + for master in font.masters: + if master.name == base_style: + return master + return font.masters[0] + + +def find_base_style(masters): + """Find a base style shared between all masters. + Return empty string if none is found. + """ + if not masters: + return '' + base_style = (masters[0].name or '').split() + for master in masters: + style = master.name.split() + base_style = [s for s in style if s in base_style] + base_style = ' '.join(base_style) + return base_style + + +def is_instance_active(instance): + # Glyphs.app recognizes both "exports=0" and "active=0" as a flag + # to mark instances as inactive. Inactive instances should get ignored. + # https://github.com/googlei18n/glyphsLib/issues/129 + return instance.exports and getattr(instance, 'active', True) + + +def interp(mapping, x): + """Compute the piecewise linear interpolation given by mapping for input x. + + >>> _interp(((1, 1), (2, 4)), 1.5) + 2.5 + """ + mapping = sorted(mapping) + if len(mapping) == 1: + xa, ya = mapping[0] + if xa == x: + return ya + return x + for (xa, ya), (xb, yb) in zip(mapping[:-1], mapping[1:]): + if xa <= x <= xb: + return ya + float(x - xa) / (xb - xa) * (yb - ya) + return x diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index e75482d2c..fa032180b 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -15,7 +15,7 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -from collections import OrderedDict +from collections import OrderedDict, defaultdict import logging import tempfile import os @@ -27,6 +27,7 @@ from glyphsLib import classes, glyphdata_generated from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX +from .axes import DEFAULT_AXES_DEFS, find_base_style, class_to_value GLYPH_ORDER_KEY = PUBLIC_PREFIX + 'glyphOrder' @@ -51,6 +52,7 @@ def __init__(self, ufo_module=defcon, designspace_module=designSpaceDocument, family_name=None, + instance_dir=None, propagate_anchors=True, use_designspace=False, minimize_glyphs_diffs=False): @@ -65,6 +67,8 @@ def __init__(self, Document. Should look like designSpaceDocument. family_name -- if provided, the master UFOs will be given this name and only instances with this name will be returned. + instance_dir -- if provided, instance UFOs will be located in this + directory, according to their Designspace filenames. propagate_anchors -- set to False to prevent anchor propagation use_designspace -- set to True to make optimal use of the designspace: data that is common to all ufos will go there. @@ -75,16 +79,24 @@ def __init__(self, self.font = font self.ufo_module = ufo_module self.designspace_module = designspace_module + self.instance_dir = instance_dir self.propagate_anchors = propagate_anchors self.use_designspace = use_designspace self.minimize_glyphs_diffs = minimize_glyphs_diffs - # The set of UFOs (= defcon.Font objects) that will be built, + # The set of (SourceDescriptor + UFO)s that will be built, # indexed by master ID, the same order as masters in the source GSFont. - self._ufos = OrderedDict() + self._sources = OrderedDict() - # The designSpaceDocument object that will be built (if requested). - self._designspace = None + # The designSpaceDocument object that will be built. + # The sources will be built in any case, at the same time that we build + # the master UFOs, when the user requests them. + # The axes, instances, rules... will only be built if the designspace + # document itself is requested by the user. + self._designspace = self.designspace_module.DesignSpaceDocument( + writerClass=designSpaceDocument.InMemoryDocWriter, + fontClass=self.ufo_module.Font) + self._designspace_is_complete = False # check that source was generated with at least stable version 2.3 # https://github.com/googlei18n/glyphsLib/pull/65#issuecomment-237158140 @@ -114,8 +126,9 @@ def __init__(self, def masters(self): """Get an iterator over master UFOs that match the given family_name. """ - if self._ufos: - return self._ufos.values() + if self._sources: + for source in self._sources.values(): + yield source.font # Store set of actually existing master (layer) ids. This helps with # catching dangling layer data that Glyphs may ignore, e.g. when @@ -147,7 +160,7 @@ def masters(self): layer_name, layer)) continue - ufo = self._ufos[layer_id] + ufo = self._sources[layer_id].font ufo_glyph = ufo.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, glyph) ufo_layer = ufo.layers.defaultLayer @@ -175,7 +188,7 @@ def masters(self): 'be skipped.'.format(self.font.familyName, glyph_name)) continue - ufo_font = self._ufos[master_id] + ufo_font = self._sources[master_id].font if layer_name not in ufo_font.layers: ufo_layer = ufo_font.newLayer(layer_name) else: @@ -188,7 +201,8 @@ def masters(self): ufo_glyph = ufo_layer.newGlyph(glyph_name) self.to_ufo_glyph(ufo_glyph, layer, layer.parent) - for ufo in self._ufos.values(): + for source in self._sources.values(): + ufo = source.font if self.propagate_anchors: self.to_ufo_propagate_font_anchors(ufo) self.to_ufo_features(ufo) # This depends on the glyphOrder key @@ -198,7 +212,8 @@ def masters(self): self.to_ufo_groups() self.to_ufo_kerning() - return self._ufos.values() + for source in self._sources.values(): + yield source.font def _layer_order_in_glyph(self, layer): # TODO: move to layers.py @@ -219,14 +234,24 @@ def designspace(self): """Get a designspace Document instance that links the masters together and holds instance data. """ - if self._designspace is not None: + if self._designspace_is_complete: return self._designspace - - self._designspace = self.designspace_module.DesignSpaceDocument( - writerClass=designSpaceDocument.InMemoryDocWriter, - fontClass=self.ufo_module.Font) + self._designspace_is_complete = True + ufos = list(self.masters) # Make sure that the UFOs are built + # FIXME: (jany) feels wrong + self.to_designspace_axes() + self.to_designspace_sources() self.to_designspace_instances() self.to_designspace_family_user_data() + + # append base style shared by all masters to designspace file name + base_family = self.font.familyName or 'Unnamed' + base_style = find_base_style(self.font.masters) + if base_style: + base_style = "-" + base_style + name = (base_family + base_style).replace(' ', '') + '.designspace' + self.designspace.filename = name + return self._designspace # DEPRECATED @@ -253,6 +278,7 @@ def instance_data(self): # Implementation is split into one file per feature from .anchors import to_ufo_propagate_font_anchors, to_ufo_glyph_anchors from .annotations import to_ufo_annotations + from .axes import to_designspace_axes from .background_image import to_ufo_background_image from .blue_values import to_ufo_blue_values from .common import to_ufo_time @@ -269,6 +295,7 @@ def instance_data(self): from .masters import to_ufo_master_attributes from .names import to_ufo_names from .paths import to_ufo_paths + from .sources import to_designspace_sources from .user_data import (to_designspace_family_user_data, to_ufo_family_user_data, to_ufo_master_user_data, to_ufo_glyph_user_data, to_ufo_layer_lib, @@ -299,6 +326,16 @@ def __init__(self, minimize_ufo_diffs=False): """Create a builder that goes from UFOs + designspace to Glyphs. + If you provide: + * Some UFOs, no designspace: the given UFOs will be combined. + No instance data will be created, only the weight and width + axes will be set up (if relevant). + * A designspace, no UFOs: the UFOs will be loaded according to + the designspace's sources. Instance and axis data will be + converted to Glyphs. + * Both a designspace and some UFOs: not supported for now. + TODO: find out whether there is a use-case here? + Keyword arguments: ufos -- The list of UFOs to combine into a GSFont designspace -- A MutatorMath Designspace to use for the GSFont @@ -318,24 +355,20 @@ def __init__(self, if designspace is not None: self.designspace = designspace if ufos: - # assert all(ufo in designspace.getFonts() for ufo in ufos) - self.ufos = ufos - else: - self.ufos = [] - for source in designspace.sources: - # FIXME: (jany) Do something better for the InMemory stuff - # Is it an in-memory source descriptor? - if not hasattr(source, 'font'): - if source.path: - source.font = designspace.fontClass(source.path) - else: - dirname = os.path.dirname(designspace.path) - ufo_path = os.path.join(dirname, source.filename) - source.font = designspace.fontClass(ufo_path) - self.ufos.append(source.font) + raise NotImplementedError + for source in designspace.sources: + # FIXME: (jany) Do something better for the InMemory stuff + # Is it an in-memory source descriptor? + if not hasattr(source, 'font'): + if source.path: + # FIXME: (jany) consider not mucking with the caller's objects + source.font = designspace.fontClass(source.path) + else: + dirname = os.path.dirname(designspace.path) + ufo_path = os.path.join(dirname, source.filename) + source.font = designspace.fontClass(ufo_path) elif ufos: self.designspace = self._fake_designspace(ufos) - self.ufos = ufos else: raise RuntimeError( 'Please provide a designspace or at least one UFO.') @@ -349,20 +382,20 @@ def font(self): if self._font is not None: return self._font - # Sort UFOS in the original order - self.to_glyphs_ordered_masters() + # Sort UFOS in the original order from the Glyphs file + sorted_sources = self.to_glyphs_ordered_masters() self._font = self.glyphs_module.GSFont() - self._ufos = OrderedDict() # Same as in UFOBuilder - for index, ufo in enumerate(self.ufos): + self._sources = OrderedDict() # Same as in UFOBuilder + for index, source in enumerate(sorted_sources): master = self.glyphs_module.GSFontMaster() - self.to_glyphs_font_attributes(ufo, master, + self.to_glyphs_font_attributes(source, master, is_initial=(index == 0)) - self.to_glyphs_master_attributes(ufo, master) + self.to_glyphs_master_attributes(source, master) self._font.masters.insert(len(self._font.masters), master) - self._ufos[master.id] = ufo + self._sources[master.id] = source - for layer in ufo.layers: + for layer in source.font.layers: self.to_glyphs_layer_lib(layer) for glyph in layer: self.to_glyphs_glyph(glyph, layer, master) @@ -371,23 +404,24 @@ def font(self): self.to_glyphs_kerning() # Now that all GSGlyph are built, restore the glyph order - for first_ufo in self.ufos: + if self.designspace.sources: + first_ufo = self.designspace.sources[0].font if GLYPH_ORDER_KEY in first_ufo.lib: glyph_order = first_ufo.lib[GLYPH_ORDER_KEY] lookup = {name: i for i, name in enumerate(glyph_order)} self.font.glyphs = sorted( self.font.glyphs, key=lambda glyph: lookup.get(glyph.name, 1 << 63)) - - # FIXME: (jany) Only do that on the first one. Maybe we should + # FIXME: (jany) We only do that on the first one. Maybe we should # merge the various `public.glyphorder` values? - break - # Restore the layer ordering in each glyph - for glyph in self._font.glyphs: - self.to_glyphs_layer_order(glyph) + # Restore the layer ordering in each glyph + for glyph in self._font.glyphs: + self.to_glyphs_layer_order(glyph) self.to_glyphs_family_user_data_from_designspace() + self.to_glyphs_axes() + self.to_glyphs_sources() self.to_glyphs_instances() return self._font @@ -399,6 +433,34 @@ def _fake_designspace(self, ufos): designspace = designSpaceDocument.DesignSpaceDocument( writerClass=designSpaceDocument.InMemoryDocWriter) + ufo_to_location = defaultdict(dict) + + # Make weight and width axis if relevant + for info_key, axis_def in zip( + ('openTypeOS2WeightClass', 'openTypeOS2WidthClass'), + DEFAULT_AXES_DEFS): + axis = designspace.newAxisDescriptor() + axis.tag = axis_def.tag + axis.name = axis_def.name + axis.labelNames = {"en": axis_def.name} + mapping = [] + for ufo in ufos: + user_loc = getattr(ufo.info, info_key) + if user_loc is not None: + design_loc = class_to_value(axis_def.tag, user_loc) + mapping.append((user_loc, design_loc)) + ufo_to_location[ufo][axis_def.name] = design_loc + + mapping = sorted(set(mapping)) + if len(mapping) > 1: + axis.map = mapping + axis.minimum = min([user_loc for user_loc, _ in mapping]) + axis.maximum = max([user_loc for user_loc, _ in mapping]) + axis.default = min(axis.maximum, + max(axis.minimum, + axis_def.default_user_loc)) + designspace.addAxis(axis) + for ufo in ufos: source = designspace.newSourceDescriptor() source.font = ufo @@ -406,34 +468,14 @@ def _fake_designspace(self, ufos): source.styleName = ufo.info.styleName # source.name = '%s %s' % (source.familyName, source.styleName) source.path = ufo.path - - # MutatorMath.DesignSpaceDocumentWriter iterates over the location - # dictionary, which is non-deterministic so it can cause test failures. - # We therefore use an OrderedDict to which we insert in axis order. - # Since glyphsLib will switch to DesignSpaceDocument once that is - # integrated into fonttools, it's not worth fixing upstream. - # https://github.com/googlei18n/glyphsLib/issues/165 - # FIXME: (jany) still needed? - # location = OrderedDict() - # for axis in self.designspace.axes: - # value_key = axis.name + 'Value' - # if axis.name.startswith('custom'): - # # FIXME: (jany) this is getting boring - # value_key = 'customValue' + axis.name[len('custom'):] - # location[axis.name] = ufo.lib.get( - # MASTER_CUSTOM_PARAM_PREFIX + value_key, DEFAULT_LOCS[axis.name]) - source.location = {} - # if font is regular: - # source.copyLib = True - # source.copyInfo = True - # source.copyGroups = True - # source.copyFeatures = True + source.location = ufo_to_location[ufo] designspace.addSource(source) return designspace # Implementation is split into one file per feature from .anchors import to_glyphs_glyph_anchors from .annotations import to_glyphs_annotations + from .axes import to_glyphs_axes from .background_image import to_glyphs_background_image from .blue_values import to_glyphs_blue_values from .components import (to_glyphs_components, @@ -451,6 +493,7 @@ def _fake_designspace(self, ufos): from .masters import to_glyphs_master_attributes from .names import to_glyphs_family_names, to_glyphs_master_names from .paths import to_glyphs_paths + from .sources import to_glyphs_sources from .user_data import (to_glyphs_family_user_data_from_designspace, to_glyphs_family_user_data_from_ufo, to_glyphs_master_user_data, diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index ec919d6ad..8c27063f6 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -53,7 +53,9 @@ def to_ufo_font_attributes(self, family_name): glyph_order = list(glyph.name for glyph in font.glyphs) for index, master in enumerate(font.masters): + source = self.designspace.newSourceDescriptor() ufo = self.ufo_module.Font() + source.font = ufo ufo.lib[APP_VERSION_LIB_KEY] = font.appVersion ufo.lib[KEYBOARD_INCREMENT_KEY] = font.keyboardIncrement @@ -81,14 +83,15 @@ def to_ufo_font_attributes(self, family_name): self.to_ufo_family_user_data(ufo) self.to_ufo_custom_params(ufo, font) - self.to_ufo_master_attributes(ufo, master) + self.to_ufo_master_attributes(source, master) ufo.lib[MASTER_ORDER_LIB_KEY] = index # FIXME: (jany) in the future, yield this UFO (for memory, lazy iter) - self._ufos[master.id] = ufo + self.designspace.addSource(source) + self._sources[master.id] = source -def to_glyphs_font_attributes(self, ufo, master, is_initial): +def to_glyphs_font_attributes(self, source, master, is_initial): """ Copy font attributes from `ufo` either to `self.font` or to `master`. @@ -104,14 +107,15 @@ def to_glyphs_font_attributes(self, ufo, master, is_initial): # modified in only one of the UFOs in a MM. Maybe do this check later, # when the roundtrip without modification works. if is_initial: - _set_glyphs_font_attributes(self, ufo) + _set_glyphs_font_attributes(self, source) else: # self._compare_and_merge_glyphs_font_attributes(ufo) pass -def _set_glyphs_font_attributes(self, ufo): +def _set_glyphs_font_attributes(self, source): font = self.font + ufo = source.font info = ufo.info if APP_VERSION_LIB_KEY in ufo.lib: @@ -147,14 +151,13 @@ def _set_glyphs_font_attributes(self, ufo): def to_glyphs_ordered_masters(self): - """Modify in-place the list of UFOs to restore their original order.""" - self.ufos = sorted(self.ufos, key=_original_master_order) + """Modify in-place the list of UFOs to restore their original order in + the Glyphs file (if any, otherwise does not change the order).""" + return sorted(self.designspace.sources, key=_original_master_order) -def _original_master_order(ufo): - # FIXME: (jany) Here we should rely on order of sources in designspace - # if self.use_designspace +def _original_master_order(source): try: - return ufo.lib[MASTER_ORDER_LIB_KEY] + return source.font.lib[MASTER_ORDER_LIB_KEY] except KeyError: - return float('infinity') + return 1 << 31 diff --git a/Lib/glyphsLib/builder/groups.py b/Lib/glyphsLib/builder/groups.py index a36f3cf3c..5a545c9a0 100644 --- a/Lib/glyphsLib/builder/groups.py +++ b/Lib/glyphsLib/builder/groups.py @@ -81,17 +81,17 @@ def to_ufo_groups(self): groups[group].append(glyph.name) # Update all UFOs with the same info - for ufo in self._ufos.values(): + for source in self._sources.values(): for name, glyphs in groups.items(): # Shallow copy to prevent unexpected object sharing - ufo.groups[name] = glyphs[:] + source.font.groups[name] = glyphs[:] def to_glyphs_groups(self): # Build the GSClasses from the groups of the first UFO. groups = [] - for ufo in self.ufos: - for name, glyphs in ufo.groups.items(): + for source in self._sources.values(): + for name, glyphs in source.font.groups.items(): if _is_kerning_group(name): _to_glyphs_kerning_group(self, name, glyphs) else: @@ -103,11 +103,11 @@ def to_glyphs_groups(self): break # Check that other UFOs are identical and print a warning if not. - for index, ufo in enumerate(self.ufos): + for index, source in enumerate(self._sources.values()): if index == 0: - reference_ufo = ufo + reference_ufo = source.font else: - _assert_groups_are_identical(self, reference_ufo, ufo) + _assert_groups_are_identical(self, reference_ufo, source.font) def _is_kerning_group(name): diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index b9fd50857..da19e4c67 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -16,253 +16,35 @@ unicode_literals) from collections import OrderedDict +import os from glyphsLib.util import build_ufo_path +from glyphsLib.classes import WEIGHT_CODES from .constants import (GLYPHS_PREFIX, GLYPHLIB_PREFIX, FONT_CUSTOM_PARAM_PREFIX, MASTER_CUSTOM_PARAM_PREFIX) from .names import build_stylemap_names from .masters import UFO_FILENAME_KEY +from .axes import get_axis_definitions, is_instance_active, interp EXPORT_KEY = GLYPHS_PREFIX + 'export' WIDTH_KEY = GLYPHS_PREFIX + 'width' WEIGHT_KEY = GLYPHS_PREFIX + 'weight' -WEIGHT_CLASS_KEY = GLYPHS_PREFIX + 'weightClass' -WIDTH_CLASS_KEY = GLYPHS_PREFIX + 'widthClass' +FULL_FILENAME_KEY = GLYPHLIB_PREFIX + 'fullFilename' MANUAL_INTERPOLATION_KEY = GLYPHS_PREFIX + 'manualInterpolation' INSTANCE_INTERPOLATIONS_KEY = GLYPHS_PREFIX + 'intanceInterpolations' def to_designspace_instances(self): """Write instance data from self.font to self.designspace.""" - - # base_family = masters[0].info.familyName - # assert all(m.info.familyName == base_family for m in masters), \ - # 'Masters must all have same family' - - # for font in masters: - # write_ufo(font, master_dir) - - # needed so that added masters and instances have correct relative paths - # tmp_path = os.path.join(master_dir, 'tmp.designspace') - # writer = DesignSpaceDocumentWriter(tmp_path) - - # instances = list(filter(is_instance_active, instance_data.get('data', []))) - ufo_masters = list(self.masters) - if ufo_masters: - varfont_origin = _get_varfont_origin(ufo_masters) - regular = _find_regular_master(ufo_masters, regularName=varfont_origin) - _to_designspace_axes(self, regular) - _to_designspace_sources(self, regular) - for instance in self.font.instances: - _to_designspace_instance(self, instance) - - -def _get_varfont_origin(masters): - # the 'Variation Font Origin' is a font-wide custom parameter, thus it is - # shared by all the master ufos; here we just get it from the first one - assert len(masters) > 0 - varfont_origin_key = "Variation Font Origin" - return masters[0].lib.get(FONT_CUSTOM_PARAM_PREFIX + varfont_origin_key) - - -def _find_regular_master(masters, regularName=None): - """Find the "regular" master among the master UFOs. - - Tries to find the master with the passed 'regularName'. - If there is no such master or if regularName is None, - tries to find a base style shared between all masters - (defaulting to "Regular"), and then tries to find a master - with that style name. If there is no master with that name, - returns the first master in the list. - """ - assert len(masters) > 0 - if regularName is not None: - for font in masters: - if font.info.styleName == regularName: - return font - base_style = find_base_style(masters) - if not base_style: - base_style = 'Regular' - for font in masters: - if font.info.styleName == base_style: - return font - return masters[0] - - -def find_base_style(masters): - """Find a base style shared between all masters. - Return empty string if none is found. - """ - base_style = masters[0].info.styleName.split() - for font in masters: - style = font.info.styleName.split() - base_style = [s for s in style if s in base_style] - base_style = ' '.join(base_style) - return base_style - - -# Glyphs.app's default values for the masters' {weight,width,custom}Value -# and for the instances' interpolation{Weight,Width,Custom} properties. -# When these values are set, they are omitted from the .glyphs source file. -# FIXME: (jany) This behaviour should be in classes.py -DEFAULT_LOCS = { - 'weight': 100, - 'width': 100, - 'custom': 0, - 'custom1': 0, - 'custom2': 0, - 'custom3': 0, -} - -WEIGHT_CODES = { - 'Thin': 250, - 'ExtraLight': 250, - 'UltraLight': 250, - 'Light': 300, - None: 400, # default value normally omitted in source - 'Normal': 400, - 'Regular': 400, - 'Medium': 500, - 'DemiBold': 600, - 'SemiBold': 600, - 'Bold': 700, - 'UltraBold': 800, - 'ExtraBold': 800, - 'Black': 900, - 'Heavy': 900, -} - -WIDTH_CODES = { - 'Ultra Condensed': 1, - 'Extra Condensed': 2, - 'Condensed': 3, - 'SemiCondensed': 4, - None: 5, # default value normally omitted in source - 'Medium (normal)': 5, - 'Semi Expanded': 6, - 'Expanded': 7, - 'Extra Expanded': 8, - 'Ultra Expanded': 9, -} - - -def _to_designspace_axes(self, regular_master): - # According to Georg Seifert, Glyphs 3 will have a better model - # for describing variation axes. The plan is to store the axis - # information globally in the Glyphs file. In addition to actual - # variation axes, this new structure will probably also contain - # stylistic information for design axes that are not variable but - # should still be stored into the OpenType STAT table. - # - # We currently take the minima and maxima from the instances, and - # have hard-coded the default value for each axis. We could be - # smarter: for the minima and maxima, we could look at the masters - # (whose locations are only stored in interpolation space, not in - # user space) and reverse-interpolate these locations to user space. - # Likewise, we could try to infer the default axis value from the - # masters. But it's probably not worth this effort, given that - # the upcoming version of Glyphs is going to store explicit - # axis desriptions in its file format. - - # FIXME: (jany) find interpolation data in GSFontMaster rather than in UFO? - # It would allow to drop the DEFAULT_LOCS dictionary - masters = list(self.masters) - instances = self.font.instances - - for name, tag, userLocParam, defaultUserLoc, codes in ( - ('weight', 'wght', 'weightClass', 400, WEIGHT_CODES), - ('width', 'wdth', 'widthClass', 100, WIDTH_CODES), - ('custom', 'XXXX', None, 0, None), - ('custom1', 'XXX1', None, 0, None), - ('custom2', 'XXX2', None, 0, None), - ('custom3', 'XXX3', None, 0, None)): - key = MASTER_CUSTOM_PARAM_PREFIX + name + 'Value' - if name.startswith('custom'): - key = MASTER_CUSTOM_PARAM_PREFIX + 'customValue' + name[len('custom'):] - if any(key in master.lib for master in masters): - axis = self.designspace.newAxisDescriptor() - axis.tag = tag - axis.name = name - regularInterpolLoc = regular_master.lib.get(key, DEFAULT_LOCS[name]) - regularUserLoc = defaultUserLoc - - labelName = name.title() - if name.startswith('custom'): - name_key = MASTER_CUSTOM_PARAM_PREFIX + 'customName' + name[len('custom'):] - for master in masters: - if name_key in master.lib: - labelName = master.lib[name_key] - break - axis.labelNames = {"en": labelName} - - interpolLocKey = name + 'Value' - if name.startswith('custom'): - interpolLocKey = 'customValue' + name[len('custom'):] - mapping = [] - for instance in instances: - interpolLoc = getattr(instance, interpolLocKey) - userLoc = interpolLoc - if userLocParam in instance.customParameters: - userLoc = float(instance.customParameters[userLocParam]) - elif (codes is not None and getattr(instance, name) and - getattr(instance, name) in codes): - userLoc = codes[getattr(instance, name)] - mapping.append((userLoc, interpolLoc)) - if interpolLoc == regularInterpolLoc: - regularUserLoc = userLoc - mapping = sorted(set(mapping)) # avoid duplicates - if mapping: - axis.minimum = min([userLoc for userLoc, _ in mapping]) - axis.maximum = max([userLoc for userLoc, _ in mapping]) - axis.default = min(axis.maximum, max(axis.minimum, regularUserLoc)) # clamp - else: - axis.minimum = axis.maximum = axis.default = defaultUserLoc - axis.map = mapping - self.designspace.addAxis(axis) - - -def _to_designspace_sources(self, regular): - """Add master UFOs to the designspace document.""" - # FIXME: (jany) maybe read data from the GSFontMasters directly? - for master, font in zip(self.font.masters, self.masters): - source = self.designspace.newSourceDescriptor() - source.font = font - source.familyName = font.info.familyName - source.styleName = font.info.styleName - source.name = '%s %s' % (source.familyName, source.styleName) - if UFO_FILENAME_KEY in master.userData: - source.filename = master.userData[UFO_FILENAME_KEY] - else: - source.filename = build_ufo_path('.', source.familyName, - source.styleName) - - # MutatorMath.DesignSpaceDocumentWriter iterates over the location - # dictionary, which is non-deterministic so it can cause test failures. - # We therefore use an OrderedDict to which we insert in axis order. - # Since glyphsLib will switch to DesignSpaceDocument once that is - # integrated into fonttools, it's not worth fixing upstream. - # https://github.com/googlei18n/glyphsLib/issues/165 - # FIXME: (jany) still needed? - location = OrderedDict() - for axis in self.designspace.axes: - value_key = axis.name + 'Value' - if axis.name.startswith('custom'): - # FIXME: (jany) this is getting boring - value_key = 'customValue' + axis.name[len('custom'):] - location[axis.name] = font.lib.get( - MASTER_CUSTOM_PARAM_PREFIX + value_key, DEFAULT_LOCS[axis.name]) - source.location = location - if font is regular: - source.copyLib = True - source.copyInfo = True - source.copyGroups = True - source.copyFeatures = True - self.designspace.addSource(source) + if is_instance_active(instance) or self.minimize_glyphs_diffs: + _to_designspace_instance(self, instance) def _to_designspace_instance(self, instance): ufo_instance = self.designspace.newInstanceDescriptor() + # FIXME: (jany) most of these customParameters are actually attributes, + # at least according to https://docu.glyphsapp.com/#fontName for p in instance.customParameters: param, value = p.name, p.value if param == 'familyName': @@ -271,55 +53,56 @@ def _to_designspace_instance(self, instance): # Glyphs uses "postscriptFontName", not "postScriptFontName" ufo_instance.postScriptFontName = value elif param == 'fileName': - ufo_instance.filename = value + '.ufo' + fname = value + '.ufo' + if self.instance_dir is not None: + fname = self.instance_dir + '/' + fname + ufo_instance.filename = fname + if ufo_instance.familyName is None: ufo_instance.familyName = self.family_name - ufo_instance.styleName = instance.name + + # TODO: investigate the possibility of storing a relative path in the + # `filename` custom parameter. If yes, drop the key below. + fname = instance.customParameters[FULL_FILENAME_KEY] + if fname is not None: + if self.instance_dir: + fname = self.instance_dir + '/' + os.path.basename(fname) + ufo_instance.filename = fname if not ufo_instance.filename: - ufo_instance.filename = build_ufo_path('.', ufo_instance.familyName, - ufo_instance.styleName) - # ofiles.append((ufo_path, instance)) - # MutatorMath.DesignSpaceDocumentWriter iterates over the location - # dictionary, which is non-deterministic so it can cause test failures. - # We therefore use an OrderedDict to which we insert in axis order. - # Since glyphsLib will switch to DesignSpaceDocument once that is - # integrated into fonttools, it's not worth fixing upstream. - # https://github.com/googlei18n/glyphsLib/issues/165 - # FIXME: (jany) still needed? - location = OrderedDict() - # FIXME: (jany) make a function for iterating axes and the related properties? - for axis in self.designspace.axes: - value_key = axis.name + 'Value' - if axis.name.startswith('custom'): - value_key = 'customValue' + axis.name[len('custom'):] - location[axis.name] = getattr(instance, value_key) + instance_dir = self.instance_dir or '.' + ufo_instance.filename = build_ufo_path( + instance_dir, ufo_instance.familyName, ufo_instance.styleName) + location = {} + for axis_def in get_axis_definitions(self.font): + location[axis_def.name] = axis_def.get_design_loc(instance) ufo_instance.location = location - ufo_instance.styleMapFamilyName, ufo_instance.styleMapStyleName = \ - build_stylemap_names( - family_name=ufo_instance.familyName, - style_name=ufo_instance.styleName, - is_bold=instance.isBold, - is_italic=instance.isItalic, - linked_style=instance.linkStyle, - ) - - ufo_instance.name = ' '.join((ufo_instance.familyName, - ufo_instance.styleName)) - - ufo_instance.lib[EXPORT_KEY] = instance.active - ufo_instance.lib[WEIGHT_KEY] = instance.weight - ufo_instance.lib[WIDTH_KEY] = instance.width - - if 'weightClass' in instance.customParameters: - ufo_instance.lib[WEIGHT_CLASS_KEY] = instance.customParameters['weightClass'] - if 'widthClass' in instance.customParameters: - ufo_instance.lib[WIDTH_CLASS_KEY] = instance.customParameters['widthClass'] - - ufo_instance.lib[INSTANCE_INTERPOLATIONS_KEY] = instance.instanceInterpolations - ufo_instance.lib[MANUAL_INTERPOLATION_KEY] = instance.manualInterpolation + # FIXME: (jany) should be the responsibility of ufo2ft? + # Anyway, only generate the styleMap names if the Glyphs instance already + # has a linkStyle set up, or if we're not round-tripping (i.e. generating + # UFOs for fontmake, the traditional use-case of glyphsLib.) + if instance.linkStyle or not self.minimize_glyphs_diffs: + ufo_instance.styleMapFamilyName, ufo_instance.styleMapStyleName = \ + build_stylemap_names( + family_name=ufo_instance.familyName, + style_name=ufo_instance.styleName, + is_bold=instance.isBold, + is_italic=instance.isItalic, + linked_style=instance.linkStyle, + ) + + ufo_instance.name = ' '.join((ufo_instance.familyName or '', + ufo_instance.styleName or '')) + + if self.minimize_glyphs_diffs: + ufo_instance.lib[EXPORT_KEY] = instance.active + ufo_instance.lib[WEIGHT_KEY] = instance.weight + ufo_instance.lib[WIDTH_KEY] = instance.width + + ufo_instance.lib[INSTANCE_INTERPOLATIONS_KEY] = instance.instanceInterpolations + ufo_instance.lib[MANUAL_INTERPOLATION_KEY] = instance.manualInterpolation # TODO: put the userData/customParameters in lib self.designspace.addInstance(ufo_instance) @@ -363,44 +146,69 @@ def to_glyphs_instances(self): instance.name = ufo_instance.styleName + for axis_def in get_axis_definitions(self.font): + design_loc = None + try: + design_loc = ufo_instance.location[axis_def.name] + axis_def.set_design_loc(instance, design_loc) + except KeyError: + # The location does not have this axis? + pass + + if axis_def.tag in ('wght', 'wdth'): + # Retrieve the user location (weightClass/widthClass) + # TODO: (jany) update comments + # First way: for UFOs/designspace of other origins, read + # the mapping backwards and check that the user location + # matches the instance's weight/width. If not, set the the + # custom param. + user_loc = design_loc + mapping = None + for axis in self.designspace.axes: + if axis.tag == axis_def.tag: + mapping = axis.map + if mapping: + reverse_mapping = [(dl, ul) for ul, dl in mapping] + user_loc = interp(reverse_mapping, design_loc) + if user_loc is not None: + axis_def.set_user_loc(instance, user_loc) + try: - instance.weight = ufo_instance.lib[WEIGHT_KEY] + # Restore the original weightClass when there is an ambiguity based + # on the value, e.g. Thin, ExtraLight, UltraLight all map to 250. + # No problem with width, because 1:1 mapping in WIDTH_CODES. + weight = ufo_instance.lib[WEIGHT_KEY] + if (not instance.weight or + WEIGHT_CODES[instance.weight] == WEIGHT_CODES[weight]): + instance.weight = weight except KeyError: # FIXME: what now pass try: - instance.width = ufo_instance.lib[WIDTH_KEY] + if not instance.width: + instance.width = ufo_instance.lib[WIDTH_KEY] except KeyError: # FIXME: what now pass - for axis in [ - 'weight', 'width', 'custom', 'custom1', 'custom2', 'custom3']: - # Retrieve the interpolation location - try: - loc = ufo_instance.location[axis] - value_key = axis + 'Value' - if axis.startswith('custom'): - value_key = 'customValue' + axis[len('custom'):] - setattr(instance, value_key, loc) - except KeyError: - # FIXME: (jany) what now? - pass + if ufo_instance.familyName is not None: + if ufo_instance.familyName != self.font.familyName: + instance.familyName = ufo_instance.familyName - for axis, lib_key in [('weight', WEIGHT_CLASS_KEY), - ('width', WIDTH_CLASS_KEY)]: - # Retrieve the user location (weightClass/widthClass) - try: - # First way: for round-tripped data, read the glyphsLib key - instance.customParameters[axis + 'Class'] = ufo_instance.lib[ - lib_key] - except KeyError: - # Second way: for UFOs/designspace of other origins, read the - # mapping backwards and check that the user location matches - # the instance's weight/width. If not, set the the custom param. - # TODO: (jany) - pass + smfn = ufo_instance.styleMapFamilyName + if smfn is not None: + if smfn.startswith(ufo_instance.familyName): + smfn = smfn[len(ufo_instance.familyName):].strip() + instance.linkStyle = smfn + + if ufo_instance.styleMapStyleName is not None: + style = ufo_instance.styleMapStyleName + instance.isBold = ('bold' in style) + instance.isItalic = ('italic' in style) + + if ufo_instance.postScriptFontName is not None: + instance.fontName = ufo_instance.postScriptFontName try: instance.manualInterpolation = ufo_instance.lib[ @@ -416,4 +224,9 @@ def to_glyphs_instances(self): # if instance.manualInterpolation: warn about data loss pass - self.font.instances.append(instance) + if self.minimize_ufo_diffs: + instance.customParameters[ + FULL_FILENAME_KEY] = ufo_instance.filename + + # FIXME: (jany) cannot `.append()` because no proxy => no parent + self.font.instances = self.font.instances + [instance] diff --git a/Lib/glyphsLib/builder/kerning.py b/Lib/glyphsLib/builder/kerning.py index afb4f2fe9..7581afe0c 100644 --- a/Lib/glyphsLib/builder/kerning.py +++ b/Lib/glyphsLib/builder/kerning.py @@ -22,7 +22,7 @@ def to_ufo_kerning(self): for master_id, kerning in self.font.kerning.items(): - _to_ufo_kerning(self, self._ufos[master_id], kerning) + _to_ufo_kerning(self, self._sources[master_id].font, kerning) def _to_ufo_kerning(self, ufo, kerning_data): @@ -99,8 +99,8 @@ def _remove_rule_if_conflict(self, ufo, seen, classname, glyph, is_left_class): def to_glyphs_kerning(self): """Add UFO kerning to GSFont.""" - for master_id, ufo in self._ufos.items(): - for (left, right), value in ufo.kerning.items(): + for master_id, source in self._sources.items(): + for (left, right), value in source.font.kerning.items(): left_match = UFO_KERN_GROUP_PATTERN.match(left) right_match = UFO_KERN_GROUP_PATTERN.match(right) if left_match: diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index 3ec94e0ca..3d4c323a2 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -15,8 +15,8 @@ from __future__ import (print_function, division, absolute_import, unicode_literals) -import uuid import os +from collections import OrderedDict from .constants import GLYPHS_PREFIX, GLYPHLIB_PREFIX @@ -27,7 +27,8 @@ UFO_NOTE_KEY = GLYPHLIB_PREFIX + 'ufoNote' -def to_ufo_master_attributes(self, ufo, master): +def to_ufo_master_attributes(self, source, master): + ufo = source.font ufo.info.ascender = master.ascender ufo.info.capHeight = master.capHeight ufo.info.descender = master.descender @@ -87,13 +88,17 @@ def to_ufo_master_attributes(self, ufo, master): ufo.lib[MASTER_ID_LIB_KEY] = master_id -def to_glyphs_master_attributes(self, ufo, master): +def to_glyphs_master_attributes(self, source, master): + ufo = source.font try: master.id = ufo.lib[MASTER_ID_LIB_KEY] except KeyError: - master.id = str(uuid.uuid4()) + # GSFontMaster has a random id by default + pass - if ufo.path and self.minimize_ufo_diffs: + if source.filename is not None and self.minimize_ufo_diffs: + master.userData[UFO_FILENAME_KEY] = source.filename + elif ufo.path and self.minimize_ufo_diffs: master.userData[UFO_FILENAME_KEY] = os.path.basename(ufo.path) master.ascender = ufo.info.ascender @@ -120,108 +125,8 @@ def to_glyphs_master_attributes(self, ufo, master): if ufo.info.note is not None: master.userData[UFO_NOTE_KEY] = ufo.info.note - # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 - source = _get_designspace_source_for_ufo(self, ufo) - for axis in ['weight', 'width']: - attr = 'openTypeOS2%sClass' % axis.capitalize() - ufo_class = getattr(ufo.info, attr) - # First, try the designspace - try: - # TODO: ??? name = source.lib[...] - # TODO: maybe handled by names.py? - # setattr(master, axis, name) - raise KeyError - except KeyError: - # Second, try the custom key - try: - setattr(master, axis, ufo.lib[GLYPHS_PREFIX + axis]) - except KeyError: - if ufo_class: - setattr(master, axis, _class_to_name(axis, ufo_class)) - - value_key = axis + 'Value' - # First, try the designspace - try: - loc = source.location[axis] - setattr(master, value_key, loc) - except KeyError: - # Second, try the custom key - try: - setattr(master, value_key, ufo.lib[GLYPHS_PREFIX + value_key]) - except KeyError: - if ufo_class: - setattr(master, value_key, _class_to_value( - axis, ufo_class)) - - for number in ('', '1', '2', '3'): - # For the custom locations, we need both the name and the value - # FIXME: (jany) not sure it's worth implementing if everything is going - # to change soon on Glyphs.app's side. - pass - # try: - # axis = 'custom' + number - # value_key = 'customValue' + number - # loc = source.location[axis] - # value_key = axis + 'Value' - # if axis.startswith('custom'): - # setattr(instance, value_key, loc) - # except KeyError: - # pass - - # name_key = GLYPHS_PREFIX + 'customName' + number - # if name_key in ufo.lib: - # custom_name = ufo.lib[name_key] - # if custom_name: - # setattr(master, 'customName' + number, custom_name) - # value_key = GLYPHS_PREFIX + 'customValue' + number - # if value_key in ufo.lib: - # custom_value = ufo.lib[value_key] - # if custom_value: - # setattr(master, 'customValue' + number, custom_value) - self.to_glyphs_blue_values(ufo, master) self.to_glyphs_master_names(ufo, master) self.to_glyphs_master_user_data(ufo, master) self.to_glyphs_guidelines(ufo, master) self.to_glyphs_custom_params(ufo, master) - - -def _get_designspace_source_for_ufo(self, ufo): - for source in self.designspace.sources: - if source.font == ufo: - return source - -# FIXME: (jany) this code/data must also be somewhere else, refactor -# From the spec: https://www.microsoft.com/typography/otspec/os2.htm#wtc -CLASSES_DICT = { - 'weight': { - 100: ('Thin', 100), - 200: ('Extra-light', 200), - 300: ('Light', 300), - 400: ('Regular', 400), - 500: ('Medium', 500), - 600: ('Semi-bold', 600), - 700: ('Bold', 700), - 800: ('Extra-bold', 800), - 900: ('Black', 900), - }, - 'width': { - 1: ('Ultra-condensed', 50), - 2: ('Extra-condensed', 62.5), - 3: ('Condensed', 75), - 4: ('Semi-condensed', 87.5), - 5: ('Medium', 100), - 6: ('Semi-expanded', 112.5), - 7: ('Expanded', 125), - 8: ('Extra-expanded', 150), - 9: ('Ultra-expanded', 200), - } -} - - -def _class_to_name(axis, ufo_class): - return CLASSES_DICT[axis][ufo_class][0] - - -def _class_to_value(axis, ufo_class): - return CLASSES_DICT[axis][ufo_class][1] diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index ca6f67c3f..9a0280987 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -24,12 +24,13 @@ def to_ufo_names(self, ufo, master, family_name): custom = master.customName is_italic = bool(master.italicAngle) - styleName = build_style_name( - width if width != 'Regular' else '', + styleName = master.name or build_style_name( + width if width != 'Medium (normal)' else '', weight if weight != 'Regular' else '', custom, is_italic ) + # FIXME: (jany) should be the responsibility of ufo2ft? styleMapFamilyName, styleMapStyleName = build_stylemap_names( family_name=family_name, style_name=styleName, @@ -65,7 +66,7 @@ def build_stylemap_names(family_name, style_name, is_bold=False, if not linked_style or linked_style == 'Regular': linked_style = _get_linked_style(style_name, is_bold, is_italic) if linked_style: - styleMapFamilyName = family_name + ' ' + linked_style + styleMapFamilyName = (family_name or '') + ' ' + linked_style else: styleMapFamilyName = family_name return styleMapFamilyName, styleMapStyleName @@ -99,29 +100,20 @@ def _get_linked_style(style_name, is_bold, is_italic): def to_glyphs_family_names(self, ufo): - # FIXME: (jany) dubious, the ufo family name is not what was in Glyphs but - # what was given as an argument to to_ufo... why? self.font.familyName = ufo.info.familyName def to_glyphs_master_names(self, ufo, master): - # One way would be to split the `ufo.info.styleName` - # and find out for each part whether it is a width, weight or customName - - # Instead we shove all of it into custom, unless we can already build the - # stylename with the currently available info in the master. - # TODO: more testing of this width = master.width weight = master.weight custom = master.customName is_italic = bool(master.italicAngle) current_stylename = build_style_name( - width if width != 'Regular' else '', + width if width != 'Medium (normal)' else '', weight if weight != 'Regular' else '', custom, is_italic ) - if current_stylename != ufo.info.styleName: - master.customName = ufo.info.styleName + master.name = ufo.info.styleName diff --git a/Lib/glyphsLib/builder/sources.py b/Lib/glyphsLib/builder/sources.py new file mode 100644 index 000000000..c53654321 --- /dev/null +++ b/Lib/glyphsLib/builder/sources.py @@ -0,0 +1,79 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import (print_function, division, absolute_import, + unicode_literals) + +import os + +from glyphsLib.util import build_ufo_path + +from .masters import UFO_FILENAME_KEY +from .axes import get_axis_definitions, get_regular_master + + +def to_designspace_sources(self): + regular_master = get_regular_master(self.font) + for master in self.font.masters: + _to_designspace_source(self, master, (master is regular_master)) + + +def _to_designspace_source(self, master, is_regular): + source = self._sources[master.id] + ufo = source.font + + if is_regular: + source.copyLib = True + source.copyInfo = True + source.copyGroups = True + source.copyFeatures = True + + source.familyName = ufo.info.familyName + source.styleName = ufo.info.styleName + # TODO: recover original source name from userData + # UFO_SOURCE_NAME_KEY + source.name = '%s %s' % (source.familyName, source.styleName) + + # TODO: (jany) make sure to use forward slashes? Maybe it should be the + # responsibility of DesignspaceDocument + if UFO_FILENAME_KEY in master.userData: + source.filename = master.userData[UFO_FILENAME_KEY] + else: + # TODO: (jany) allow another naming convention? + source.filename = os.path.basename( + # FIXME: (jany) have this function not write the dot + build_ufo_path('.', source.familyName, source.styleName)) + + location = {} + for axis_def in get_axis_definitions(self.font): + location[axis_def.name] = axis_def.get_design_loc(master) + source.location = location + + +def to_glyphs_sources(self): + for master in self.font.masters: + _to_glyphs_source(self, master) + + +def _to_glyphs_source(self, master): + source = self._sources[master.id] + + # Retrieve the master locations: weight, width, custom 0 - 1 - 2 - 3 + for axis_def in get_axis_definitions(self.font): + try: + axis_def.set_design_loc(master, source.location[axis_def.name]) + except KeyError: + # The location does not have this axis? + pass + diff --git a/Lib/glyphsLib/builder/user_data.py b/Lib/glyphsLib/builder/user_data.py index 63d09a3bc..b011a457c 100644 --- a/Lib/glyphsLib/builder/user_data.py +++ b/Lib/glyphsLib/builder/user_data.py @@ -30,7 +30,9 @@ def to_designspace_family_user_data(self): if self.use_designspace: - self.designspace.lib.update(dict(self.font.userData)) + for key, value in dict(self.font.userData).items(): + if _user_data_has_no_special_meaning(key): + self.designspace.lib[key] = value def to_ufo_family_user_data(self, ufo): diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index c8d4d8f48..12bdc3dda 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -150,6 +150,38 @@ RTLTTB = 2 +WEIGHT_CODES = { + 'Thin': 250, + 'ExtraLight': 250, + 'UltraLight': 250, + 'Light': 300, + None: 400, # default value normally omitted in source + 'Normal': 400, + 'Regular': 400, + 'Medium': 500, + 'DemiBold': 600, + 'SemiBold': 600, + 'Bold': 700, + 'UltraBold': 800, + 'ExtraBold': 800, + 'Black': 900, + 'Heavy': 900, +} + +WIDTH_CODES = { + 'Ultra Condensed': 1, + 'Extra Condensed': 2, + 'Condensed': 3, + 'SemiCondensed': 4, + None: 5, # default value normally omitted in source + 'Medium (normal)': 5, + 'Semi Expanded': 6, + 'Expanded': 7, + 'Extra Expanded': 8, + 'Ultra Expanded': 9, +} + + class OnlyInGlyphsAppError(NotImplementedError): def __init__(self): NotImplementedError.__init__(self, "This property/method is only available in the real UI-based version of Glyphs.app.") @@ -1193,7 +1225,9 @@ class GSFontMaster(GSBase): "iconName": str, "id": str, "italicAngle": float, - "name": unicode, + "name": unicode, # FIXME: (jany) does not seem to be filled in by + # Glyphs 1113, instead chops up the name into + # weight or custom. "userData": dict, "verticalStems": int, "visible": bool, @@ -1204,10 +1238,17 @@ class GSFontMaster(GSBase): "xHeight": float, } _defaultsForName = { + # FIXME: (jany) In the latest Glyphs (1113), masters don't have a width + # and weight anymore as attributes, even though those properties are + # still written to the saved files. "weight": "Regular", - "width": "Regular", + "width": "Medium (normal)", "weightValue": 100.0, "widthValue": 100.0, + "customValue": 0.0, + "customValue1": 0.0, + "customValue2": 0.0, + "customValue3": 0.0, "xHeight": 500, "capHeight": 700, "ascender": 800, @@ -1252,6 +1293,7 @@ class GSFontMaster(GSBase): def __init__(self): super(GSFontMaster, self).__init__() + self.id = str(uuid.uuid4()) self.font = None self._name = None self._customParameters = [] @@ -1266,10 +1308,10 @@ def __repr__(self): (self.name, self.widthValue, self.weightValue) def shouldWriteValueForKey(self, key): - if key in ("width", "weight"): - if getattr(self, key) == "Regular": - return False - return True + if key == "width": + return getattr(self, key) != "Medium (normal)" + if key == "weight": + return getattr(self, key) != "Regular" if key in ("xHeight", "capHeight", "ascender", "descender"): # Always write those values return True @@ -1281,34 +1323,42 @@ def shouldWriteValueForKey(self, key): @property def name(self): - # FIXME: (jany) this getter looks stupid, it never returns the value - # from self._name. TODO: test what Glyphs does and how this makes sense - name = self.customParameters["Master Name"] - if name is None: - names = [self.weight, self.width] - for number in ('', '1', '2', '3'): - custom_name = getattr(self, 'customName' + number) - if (custom_name and len(custom_name) and - custom_name not in names): - names.append(custom_name) - - if len(names) > 1 and "Regular" in names: - names.remove("Regular") - - if abs(self.italicAngle) > 0.01: - names.append("Italic") - name = " ".join(list(names)) - self._name = name - return name + name = self.customParameters['Master Name'] + if name: + return name + if self._name: + return self._name + return self._joinName() @name.setter def name(self, value): - # FIXME: (jany) this is called during init while there are no - # customparameters defined yet and it crashes, so I added the if - # because during init it sets an empty string - if value: - self._name = value - # self.customParameters["Master Name"] = value + # This is what Glyphs 1113 seems to be doing, approximately. + self.weight, self.width, self.customName = self._splitName(value) + + def _joinName(self): + names = [self.weight, self.width, self.customName] + names = [n for n in names if n] # Remove None and empty string + if len(names) > 1 and "Medium (normal)" in names: + names.remove("Medium (normal)") + if len(names) > 1 and "Regular" in names: + names.remove("Regular") + if abs(self.italicAngle) > 0.01: + names.append("Italic") + return " ".join(list(names)) + + def _splitName(self, value): + if value is None: + value = '' + weight = 'Regular' + width = 'Medium (normal)' + custom = '' + names = value.split(" ") + if names and names[0] in WEIGHT_CODES: + weight = names.pop(0) + if names and names[0] in WIDTH_CODES: + witdh = names.pop(0) + custom = " ".join(names) + return (weight, width, custom) customParameters = property( lambda self: CustomParametersProxy(self), @@ -2246,8 +2296,6 @@ def __init__(self): self.visible = True self.isBold = False self.isItalic = False - self.widthClass = "Medium (normal)" - self.weightClass = "Regular" self._customParameters = [] customParameters = property( @@ -2272,7 +2320,7 @@ def familyName(self): @familyName.setter def familyName(self, value): - self.customParameters["famiyName"] = value + self.customParameters["familyName"] = value @property def preferredFamily(self): @@ -3003,6 +3051,7 @@ def masterForId(self, key): return master return None + # FIXME: (jany) Why is this not a FontInstanceProxy? @property def instances(self): return self._instances diff --git a/Lib/glyphsLib/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py index 84b1519f6..505691970 100644 --- a/Lib/glyphsLib/designSpaceDocument.py +++ b/Lib/glyphsLib/designSpaceDocument.py @@ -58,6 +58,15 @@ def from_plist(element): return plistlib.loads(string, fmt=plistlib.FMT_XML) +def posix(path): + """Normalize paths using forward slash to work also on Windows.""" + new_path = posixpath.join(*path.split(os.path.sep)) + if path.startswith('/'): + # The above transformation loses absolute paths + new_path = '/' + new_path + return new_path + + class DesignSpaceDocumentError(Exception): def __init__(self, msg, obj=None): self.msg = msg @@ -111,8 +120,8 @@ class SourceDescriptor(SimpleDescriptor): def __init__(self): self.document = None # a reference to the parent document - self.filename = None # the original path as found in the document - self.path = None # the absolute path, calculated from filename + self._filename = None # the original path as found in the document + self._path = None # the absolute path, calculated from filename self.name = None self.location = None self.copyLib = False @@ -125,6 +134,28 @@ def __init__(self): self.familyName = None self.styleName = None + @property + def filename(self): + return self._filename + + @filename.setter + def filename(self, value): + if value is None: + self._filename = None + return + self._filename = posix(value) + + @property + def path(self): + return self._path + + @path.setter + def path(self, value): + if value is None: + self._path = None + return + self._path = posix(value) + class RuleDescriptor(SimpleDescriptor): """ @@ -211,8 +242,8 @@ class InstanceDescriptor(SimpleDescriptor): 'lib'] def __init__(self): - self.filename = None # the original path as found in the document - self.path = None # the absolute path, calculated from filename + self._filename = None # the original path as found in the document + self._path = None # the absolute path, calculated from filename self.name = None self.location = None self.familyName = None @@ -230,6 +261,28 @@ def __init__(self): self.info = True self.lib = {} + @property + def filename(self): + return self._filename + + @filename.setter + def filename(self, value): + if value is None: + self._filename = None + return + self._filename = posix(value) + + @property + def path(self): + return self._path + + @path.setter + def path(self, value): + if value is None: + self._path = None + return + self._path = posix(value) + def setStyleName(self, styleName, languageCode="en"): self.localisedStyleName[languageCode] = styleName def getStyleName(self, languageCode="en"): @@ -1013,6 +1066,7 @@ class DesignSpaceDocument(object): def __init__(self, readerClass=None, writerClass=None, fontClass=None): self.logger = logging.getLogger("DesignSpaceDocumentLog") self.path = None + self.filename = None # A preferred filename for the document self.formatVersion = None self.sources = [] self.instances = [] @@ -1048,6 +1102,7 @@ def write(self, path): writer.write() def _posixRelativePath(self, otherPath): + # FIXME: (jany) not needed anymore thanks to the descriptor accessors? relative = os.path.relpath(otherPath, os.path.dirname(self.path)) return posixpath.join(*relative.split(os.path.sep)) diff --git a/Lib/glyphsLib/util.py b/Lib/glyphsLib/util.py index 557a894c5..da3f9f4c1 100644 --- a/Lib/glyphsLib/util.py +++ b/Lib/glyphsLib/util.py @@ -27,8 +27,8 @@ def build_ufo_path(out_dir, family_name, style_name): return os.path.join( out_dir, '%s-%s.ufo' % ( - family_name.replace(' ', ''), - style_name.replace(' ', ''))) + (family_name or '').replace(' ', ''), + (style_name or '').replace(' ', ''))) def write_ufo(ufo, out_dir): diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index 70f148e60..a3183fe64 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -764,7 +764,7 @@ def test_lib_weight(self): def test_lib_no_width(self): font = generate_minimal_font() ufo = to_ufos(font)[0] - self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'width'], 'Regular') + self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'width'], 'Medium (normal)') def test_lib_width(self): font = generate_minimal_font() diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py new file mode 100644 index 000000000..37003df59 --- /dev/null +++ b/tests/builder/interpolation_test.py @@ -0,0 +1,560 @@ +# coding=UTF-8 +# +# Copyright 2017 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import (print_function, division, absolute_import, + unicode_literals) +import difflib +import os.path +import shutil +import sys +import tempfile +import unittest +import xml.etree.ElementTree as etree + +# import defcon +from fontTools.misc.py23 import open +from glyphsLib.builder.constants import GLYPHS_PREFIX +# from glyphsLib.builder.interpolation import ( +# build_designspace, set_weight_class, set_width_class, build_stylemap_names +# ) +from glyphsLib.classes import GSFont, GSFontMaster, GSInstance +from glyphsLib import to_designspace, to_glyphs + + +# Current limitation of glyphsLib for designspace to designspace round-trip: +# the designspace's axes, sources and instances must be as such: +# - the axes' min, max must match extreme masters +# - the axes' default must match the "regular master" +# - the axes' mapping must have as many pairs as instances, each pair +# matching the (userLoc, designLoc) of an instance. If there are no +# instances, same requirement but with 1 pair/master +# +# Basically this is to say that the designspace must have been generated by +# glyphsLib in the first place. +# +# More general designspaces (like: axes that go farther than extreme masters, +# mapping with arbitrary numbers of pairs...) might be supported later, if/when +# Glyphs gets a UI to setup this information. +# +# REVIEW: check that the above makes sense + + +def makeFamily(): + m1 = makeMaster("Regular", weight=90.0) + m2 = makeMaster("Black", weight=190.0) + instances = [ + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Semibold", weight=("SemiBold", 600, 128)), + makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), + makeInstance("Black", weight=("Black", 900, 190)), + ] + return [m1, m2], instances + + +def makeMaster(styleName, weight=None, width=None): + m = GSFontMaster() + m.name = styleName + if weight is not None: + m.weightValue = weight + if width is not None: + m.widthValue = width + return m + + +def makeInstance(name, weight=None, width=None, is_bold=None, is_italic=None, + linked_style=None): + inst = GSInstance() + inst.name = name + if weight is not None: + # Glyphs 2.5 stores the instance weight in two to three places: + # 1. as a textual weight (such as “Bold”; no value defaults to + # "Regular"); + # 2. (optional) as numeric customParameters.weightClass (such as 700), + # which corresponds to OS/2.usWeightClass where 100 means Thin, + # 400 means Regular, 700 means Bold, and 900 means Black; + # 3. as numeric weightValue (such as 66.0), which typically is + # the stem width but can be anything that works for interpolation + # (no value defaults to 100). + weightName, weightClass, interpolationWeight = weight + if weightName is not None: + inst.weight = weightName + if weightClass is not None: + inst.customParameters["weightClass"] = weightClass + if interpolationWeight is not None: + inst.weightValue = interpolationWeight + if width is not None: + # Glyphs 2.5 stores the instance width in two to three places: + # 1. as a textual width (such as “Condensed”; no value defaults + # to "Medium (normal)"); + # 2. (optional) as numeric customParameters.widthClass (such as 5), + # which corresponds to OS/2.usWidthClass where 1 means Ultra- + # condensed, 5 means Medium (normal), and 9 means Ultra-expanded; + # 3. as numeric widthValue (such as 79), which typically is + # a percentage of whatever the font designer considers “normal” + # but can be anything that works for interpolation (no value + # defaults to 100). + widthName, widthClass, interpolationWidth = width + if widthName is not None: + inst.width = widthName + if widthClass is not None: + inst.customParameters["widthClass"] = widthClass + if interpolationWidth is not None: + inst.widthValue = interpolationWidth + # TODO: Support custom axes; need to triple-check how these are encoded in + # Glyphs files. Glyphs 3 will likely overhaul the representation of axes. + if is_bold is not None: + inst.isBold = is_bold + if is_italic is not None: + inst.isItalic = is_italic + if linked_style is not None: + inst.linkStyle = linked_style + return inst + + +def makeFont(masters, instances, familyName): + font = GSFont() + font.familyName = familyName + font.masters = masters + font.instances = instances + return font + + +class DesignspaceTest(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def write_to_tmp_path(self, doc, name): + path = os.path.join(self.tmpdir, name) + doc.write(path) + return path + + def expect_designspace(self, doc, expected_name): + dirname = os.path.dirname(__file__) + expected_path = os.path.join(dirname, "..", "data", expected_name) + return self._expect_designspace(doc, expected_path) + + def _expect_designspace(self, doc, expected_path): + actual_path = self.write_to_tmp_path(doc, 'generated.designspace') + with open(actual_path, mode="r", encoding="utf-8") as f: + actual = f.readlines() + with open(expected_path, mode="r", encoding="utf-8") as f: + expected = f.readlines() + if actual != expected: + expected_name = os.path.basename(expected_path) + for line in difflib.unified_diff( + expected, actual, + fromfile=expected_name, tofile=""): + sys.stderr.write(line) + self.fail("*.designspace file is different from expected") + + def expect_designspace_roundtrip(self, doc): + actual_path = self.write_to_tmp_path(doc, 'original.designspace') + font = to_glyphs(doc, minimize_ufo_diffs=True) + rtdoc = to_designspace(font) + return self._expect_designspace(rtdoc, actual_path) + + def test_basic(self): + masters, instances = makeFamily() + font = makeFont(masters, instances, "DesignspaceTest Basic") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestBasic.designspace") + self.expect_designspace_roundtrip(doc) + + def test_inactive_from_exports(self): + # Glyphs.app recognizes exports=0 as a flag for inactive instances. + # https://github.com/googlei18n/glyphsLib/issues/129 + masters, instances = makeFamily() + for inst in instances: + if inst.name != "Semibold": + inst.exports = False + font = makeFont(masters, instances, "DesignspaceTest Inactive") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestInactive.designspace") + self.expect_designspace_roundtrip(doc) + + def test_familyName(self): + masters, _ = makeFamily() + customFamily = makeInstance("Regular", weight=("Bold", 600, 151)) + customFamily.customParameters["familyName"] = "Custom Family" + instances = [ + makeInstance("Regular", weight=("Regular", 400, 90)), + customFamily, + ] + font = makeFont(masters, instances, "DesignspaceTest FamilyName") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestFamilyName.designspace") + self.expect_designspace_roundtrip(doc) + + def test_fileName(self): + masters, _ = makeFamily() + customFileName = makeInstance("Regular", weight=("Bold", 600, 151)) + customFileName.customParameters["fileName"] = "Custom FileName" + instances = [ + makeInstance("Regular", weight=("Regular", 400, 90)), + customFileName, + ] + font = makeFont(masters, instances, "DesignspaceTest FamilyName") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestFileName.designspace") + self.expect_designspace_roundtrip(doc) + + def test_noRegularMaster(self): + # Currently, fonttools.varLib fails to build variable fonts + # if the default axis value does not happen to be at the + # location of one of the interpolation masters. + # glyhpsLib tries to work around this downstream limitation. + masters = [ + makeMaster("Thin", weight=26), + makeMaster("Black", weight=190), + ] + instances = [ + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Bold", weight=("Thin", 100, 26)), + ] + font = makeFont(masters, instances, "NoRegularMaster") + designspace = to_designspace(font, instance_dir='out') + path = self.write_to_tmp_path(designspace, 'noregular.designspace') + doc = etree.parse(path) + weightAxis = doc.find('axes/axis[@tag="wght"]') + self.assertEqual(weightAxis.attrib["minimum"], "100") + self.assertEqual(weightAxis.attrib["default"], "100") # not 400 + self.assertEqual(weightAxis.attrib["maximum"], "900") + + self.expect_designspace_roundtrip(designspace) + + def test_postscriptFontName(self): + master = makeMaster("Master") + thin, black = makeInstance("Thin"), makeInstance("Black") + black.customParameters["postscriptFontName"] = "PSNameTest-Superfat" + font = makeFont([master], [thin, black], "PSNameTest") + designspace = to_designspace(font, instance_dir='out') + path = self.write_to_tmp_path(designspace, 'psname.designspace') + d = etree.parse(path) + + def psname(doc, style): + inst = doc.find('instances/instance[@stylename="%s"]' % style) + return inst.attrib.get('postscriptfontname') + self.assertIsNone(psname(d, "Thin")) + self.assertEqual(psname(d, "Black"), "PSNameTest-Superfat") + + self.expect_designspace_roundtrip(designspace) + + def test_instanceOrder(self): + # The generated *.designspace file should place instances + # in the same order as they appear in the original source. + # https://github.com/googlei18n/glyphsLib/issues/113 + masters, _ = makeFamily() + instances = [ + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), + ] + font = makeFont(masters, instances, "DesignspaceTest InstanceOrder") + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, + "DesignspaceTestInstanceOrder.designspace") + self.expect_designspace_roundtrip(doc) + + def test_twoAxes(self): + # In NotoSansArabic-MM.glyphs, the regular width only contains + # parameters for the weight axis. For the width axis, glyphsLib + # should use 100 as default value (just like Glyphs.app does). + familyName = "DesignspaceTest TwoAxes" + masters = [ + makeMaster("Regular", weight=90), + makeMaster("Black", weight=190), + makeMaster("Thin", weight=26), + makeMaster("ExtraCond", weight=90, width=70), + makeMaster("ExtraCond Black", weight=190, width=70), + makeMaster("ExtraCond Thin", weight=26, width=70), + ] + instances = [ + makeInstance("Thin", weight=("Thin", 100, 26)), + makeInstance("Regular", weight=("Regular", 400, 90)), + makeInstance("Semibold", weight=("SemiBold", 600, 128)), + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("ExtraCondensed Thin", + weight=("Thin", 100, 26), + width=("Extra Condensed", 2, 70)), + makeInstance("ExtraCondensed", + weight=("Regular", 400, 90), + width=("Extra Condensed", 2, 70)), + makeInstance("ExtraCondensed Black", + weight=("Black", 900, 190), + width=("Extra Condensed", 2, 70)), + ] + font = makeFont(masters, instances, familyName) + doc = to_designspace(font, instance_dir='out') + self.expect_designspace(doc, "DesignspaceTestTwoAxes.designspace") + self.expect_designspace_roundtrip(doc) + + def test_variationFontOrigin(self): + # Glyphs 2.4.1 introduced a custom parameter “Variation Font Origin” + # to specify which master should be considered the origin. + # https://glyphsapp.com/blog/glyphs-2-4-1-released + masters = [ + makeMaster("Thin", weight=26), + makeMaster("Regular", weight=100), + makeMaster("Medium", weight=111), + makeMaster("Black", weight=190), + ] + instances = [ + makeInstance("Black", weight=("Black", 900, 190)), + makeInstance("Medium", weight=("Medium", 444, 111)), + makeInstance("Regular", weight=("Regular", 400, 100)), + makeInstance("Thin", weight=("Thin", 100, 26)), + ] + font = makeFont(masters, instances, "Family") + font.customParameters["Variation Font Origin"] = "Medium" + designspace = to_designspace(font, instance_dir='out') + path = self.write_to_tmp_path(designspace, 'varfontorig.designspace') + doc = etree.parse(path) + medium = doc.find('sources/source[@stylename="Medium"]') + self.assertEqual(medium.find("lib").attrib["copy"], "1") + weightAxis = doc.find('axes/axis[@tag="wght"]') + self.assertEqual(weightAxis.attrib["default"], "444") + + self.expect_designspace_roundtrip(designspace) + + def test_designspace_name(self): + doc = to_designspace(makeFont([ + makeMaster("Regular", weight=100), + makeMaster("Bold", weight=190), + ], [], "Family Name")) + # no shared base style name, only write the family name + self.assertEqual(doc.filename, "FamilyName.designspace") + + doc = to_designspace(makeFont([ + makeMaster("Italic", weight=100), + makeMaster("Bold Italic", weight=190), + ], [], "Family Name")) + # 'Italic' is the base style; append to designspace name + self.assertEqual(doc.filename, "FamilyName-Italic.designspace") + + +# def test_designspace_roundtrip(tmpdir): +# return +# doc = DesignSpaceDocument() +# +# weight = doc.newAxisDescriptor() +# weight.minimum = 1 +# weight.maximum = 1000 +# weight.default = 400 +# weight.name = "weight" +# weight.tag = "wght" +# weight.labelNames['fa-IR'] = "قطر" +# weight.labelNames['en'] = "Wéíght" +# weight.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] +# doc.addAxis(weight) +# +# # No width axis (to check that a default one is not created) +# +# # One custom axis +# craziness = doc.newAxisDescriptor() +# craziness.minimum = -0.5 +# craziness.maximum = 0.5 +# craziness.default = 0 +# craziness.name = 'craziness' +# craziness.tag = 'CRZY' +# craziness.labelNames['en'] = 'Craziness' +# craziness.map = [] +# doc.addAxis(craziness) +# +# # Sources +# +# light_narrow = doc.newSourceDescriptor() +# light_narrow.font = defcon.Font() +# light_narrow.filename = 'sources/Cool Font LtNw.ufo' +# light_narrow.name = 'light_narrow' +# light_narrow.copyLib = True +# light_narrow.copyInfo = True +# light_narrow.copyFeatures = True +# light_narrow.location = dict( +# weight=1, # FIXME: should it be 1 or 10? +# craziness=0) +# light_narrow.familyName = "Cool Font" +# light_narrow.styleName = "Narrow Light" +# light_narrow.mutedGlyphNames.append("A") +# light_narrow.mutedGlyphNames.append("Z") +# doc.addSource(light_narrow) +# +# bold_narrow = doc.newSourceDescriptor() +# bold_narrow.font = defcon.Font() +# bold_narrow.filename = 'sources/Cool Font BdNw.ufo' +# bold_narrow.name = 'bold_narrow' +# bold_narrow.location = dict( +# weight=990, # FIXME: Should it be 1000 or 990? +# craziness=0) +# bold_narrow.familyName = "Cool Font" +# bold_narrow.styleName = "Narrow Bold" +# doc.addSource(bold_narrow) +# +# light_narrow_crazy = doc.newSourceDescriptor() +# light_narrow_crazy.font = defcon.Font() +# light_narrow_crazy.filename = 'sources/Cool Font BdNw.ufo' +# light_narrow_crazy.name = 'light_narrow' +# light_narrow_crazy.location = dict( +# weight=1, # FIXME: should it be 1 or 10? +# craziness=0.5) +# light_narrow_crazy.familyName = "Cool Font" +# light_narrow_crazy.styleName = "Narrow Bold" +# doc.addSource(light_narrow_crazy) +# +# # A source with mostly blank attributes to check that it does not crash +# out_of_place = doc.newSourceDescriptor() +# out_of_place.font = defcon.Font() +# doc.addSource(out_of_place) +# +# # Instances +# +# # TODO +# +# font = to_glyphs(doc) +# +# # TODO: check how stuff is stored for Glyphs +# +# rtdoc = to_designspace(font) +# +# # Compare +# path = os.path.join(str(tmpdir), 'original.designspace') +# doc.write(path) +# with open(path) as fp: +# xml = fp.read() +# rtpath = os.path.join(str(tmpdir), 'rt.designspace') +# rtdoc.write(rtpath) +# with open(rtpath) as fp: +# rtxml = fp.read() +# +# assert xml == rtxml +# +# +# WEIGHT_CLASS_KEY = GLYPHS_PREFIX + "weightClass" +# WIDTH_CLASS_KEY = GLYPHS_PREFIX + "widthClass" +# +# +# class SetWeightWidthClassesTest(unittest.TestCase): +# +# def test_no_weigth_class(self): +# ufo = defcon.Font() +# # name here says "Bold", however no excplit weightClass +# # is assigned +# set_weight_class(ufo, makeInstance("Bold")) +# # the default OS/2 weight class is set +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") +# +# def test_weight_class(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Bold", +# weight=("Bold", None, 150) +# ) +# +# set_weight_class(ufo, data) +# +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") +# +# def test_explicit_default_weight(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Regular", +# weight=("Regular", None, 100) +# ) +# +# set_weight_class(ufo, data) +# # the default OS/2 weight class is set +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") +# +# def test_no_width_class(self): +# ufo = defcon.Font() +# # no explicit widthClass set, instance name doesn't matter +# set_width_class(ufo, makeInstance("Normal")) +# # the default OS/2 width class is set +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") +# +# def test_width_class(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Condensed", +# width=("Condensed", 80) +# ) +# +# set_width_class(ufo, data) +# +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") +# +# def test_explicit_default_width(self): +# ufo = defcon.Font() +# data = makeInstance( +# "Regular", +# width=("Medium (normal)", 100) +# ) +# +# set_width_class(ufo, data) +# # the default OS/2 width class is set +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) +# # non-empty value is stored in the UFO lib even if same as default +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") +# +# def test_weight_and_width_class(self): +# ufo = defcon.Font() +# data = makeInstance( +# "SemiCondensed ExtraBold", +# weight=("ExtraBold", None, 160), +# width=("SemiCondensed", 90) +# ) +# +# set_weight_class(ufo, data) +# set_width_class(ufo, data) +# +# self.assertEqual(ufo.info.openTypeOS2WeightClass, 800) +# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "ExtraBold") +# self.assertEqual(ufo.info.openTypeOS2WidthClass, 4) +# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") +# +# def test_unknown_weight_class(self): +# ufo = defcon.Font() +# # "DemiLight" is not among the predefined weight classes listed in +# # Glyphs.app/Contents/Frameworks/GlyphsCore.framework/Versions/A/ +# # Resources/weights.plist +# # NOTE It is not possible from the user interface to set a custom +# # string as instance 'weightClass' since the choice is constrained +# # by a drop-down menu. +# data = makeInstance( +# "DemiLight Italic", +# weight=("DemiLight", 350, 70) +# ) +# +# set_weight_class(ufo, data) +# +# # we do not set any OS/2 weight class; user needs to provide +# # a 'weightClass' custom parameter in this special case +# self.assertTrue(ufo.info.openTypeOS2WeightClass is None) + + +if __name__ == "__main__": + sys.exit(unittest.main()) diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 0fe9512fc..963346b92 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -273,7 +273,8 @@ def test_bad_ufo_date_format_in_glyph_lib(): def test_have_default_interpolation_values(): - """When no designspace is provided, make sure that the Glyphs file has some default "axis positions" for the masters. + """When no designspace is provided, make sure that the Glyphs file has some + default "axis positions" for the masters. """ thin = defcon.Font() thin.info.openTypeOS2WidthClass = 5 diff --git a/tests/classes_test.py b/tests/classes_test.py index ea2c27f61..d432cd9fd 100755 --- a/tests/classes_test.py +++ b/tests/classes_test.py @@ -568,17 +568,20 @@ def test_name(self): master.customName = 'Rounded' self.assertEqual('Light Rounded', master.name) - master.customName1 = 'Stretched' - master.customName2 = 'Filled' - master.customName3 = 'Rotated' + master.customName = 'Rounded Stretched Filled Rotated' self.assertEqual('Light Rounded Stretched Filled Rotated', master.name) - master.customName1 = '' - master.customName2 = '' - self.assertEqual('Light Rounded Rotated', master.name) master.customName = '' - master.customName3 = '' self.assertEqual('Light', master.name) + def test_default_values(self): + master = GSFontMaster() + self.assertEqual(master.weightValue, 100.0) + self.assertEqual(master.widthValue, 100.0) + self.assertEqual(master.customValue, 0.0) + self.assertEqual(master.customValue1, 0.0) + self.assertEqual(master.customValue2, 0.0) + self.assertEqual(master.customValue3, 0.0) + class GSAlignmentZoneFromFileTest(GSObjectsTestCase): @@ -700,6 +703,15 @@ def test_attributes(self): # TODO generate() + def test_default_values(self): + instance = GSInstance() + self.assertEqual(instance.weightValue, 100.0) + self.assertEqual(instance.widthValue, 100.0) + self.assertEqual(instance.customValue, 0.0) + self.assertEqual(instance.customValue1, 0.0) + self.assertEqual(instance.customValue2, 0.0) + self.assertEqual(instance.customValue3, 0.0) + class GSGlyphFromFileTest(GSObjectsTestCase): diff --git a/tests/data/DesignspaceTestBasic.designspace b/tests/data/DesignspaceTestBasic.designspace index f4e058a7b..385494239 100644 --- a/tests/data/DesignspaceTestBasic.designspace +++ b/tests/data/DesignspaceTestBasic.designspace @@ -1,12 +1,12 @@ - - - - - + Weight + + + + @@ -16,43 +16,43 @@ - + - + - + - + - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestFamilyName.designspace b/tests/data/DesignspaceTestFamilyName.designspace index 8ff5a4090..04564d1e7 100644 --- a/tests/data/DesignspaceTestFamilyName.designspace +++ b/tests/data/DesignspaceTestFamilyName.designspace @@ -1,10 +1,10 @@ - - - + Weight + + @@ -14,29 +14,29 @@ - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestFileName.designspace b/tests/data/DesignspaceTestFileName.designspace index 02031ff6b..60c683cad 100644 --- a/tests/data/DesignspaceTestFileName.designspace +++ b/tests/data/DesignspaceTestFileName.designspace @@ -1,10 +1,10 @@ - - - + Weight + + @@ -14,29 +14,29 @@ - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestInactive.designspace b/tests/data/DesignspaceTestInactive.designspace index f7ffd626b..e99eb4420 100644 --- a/tests/data/DesignspaceTestInactive.designspace +++ b/tests/data/DesignspaceTestInactive.designspace @@ -1,9 +1,9 @@ - - + Weight + @@ -13,22 +13,22 @@ - + - + - + - + diff --git a/tests/data/DesignspaceTestInstanceOrder.designspace b/tests/data/DesignspaceTestInstanceOrder.designspace index 1600debd0..69f920d4d 100644 --- a/tests/data/DesignspaceTestInstanceOrder.designspace +++ b/tests/data/DesignspaceTestInstanceOrder.designspace @@ -1,11 +1,11 @@ - - - - + Weight + + + @@ -15,36 +15,36 @@ - + - + - + - + - + - + - + - + diff --git a/tests/data/DesignspaceTestTwoAxes.designspace b/tests/data/DesignspaceTestTwoAxes.designspace index 9d858f89c..b2c4a6052 100644 --- a/tests/data/DesignspaceTestTwoAxes.designspace +++ b/tests/data/DesignspaceTestTwoAxes.designspace @@ -1,17 +1,17 @@ - - - - - + Weight + + + + - + + Width - Width @@ -21,97 +21,97 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + diff --git a/tests/interpolation_test.py b/tests/interpolation_test.py deleted file mode 100644 index 63aa75969..000000000 --- a/tests/interpolation_test.py +++ /dev/null @@ -1,408 +0,0 @@ -# coding=UTF-8 -# -# Copyright 2017 Google Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import (print_function, division, absolute_import, - unicode_literals) -import difflib -import os.path -import shutil -import sys -import tempfile -import unittest -import xml.etree.ElementTree as etree - -import defcon -from fontTools.misc.py23 import open -from glyphsLib.builder.constants import GLYPHS_PREFIX -from glyphsLib.builder.interpolation import ( - build_designspace, set_weight_class, set_width_class, build_stylemap_names -) -from glyphsLib.classes import GSInstance, GSCustomParameter - - -def makeFamily(familyName): - m1 = makeMaster(familyName, "Regular", weight=90.0) - m2 = makeMaster(familyName, "Black", weight=190.0) - instances = { - "data": [ - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Semibold", weight=("Semibold", 600, 128)), - makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), - makeInstance("Black", weight=("Black", 900, 190)), - ], - } - return [m1, m2], instances - - -def makeMaster(familyName, styleName, weight=None, width=None): - m = defcon.Font() - m.info.familyName, m.info.styleName = familyName, styleName - if weight is not None: - m.lib[GLYPHS_PREFIX + "weightValue"] = weight - if width is not None: - m.lib[GLYPHS_PREFIX + "widthValue"] = width - return m - - -def makeInstance(name, weight=None, width=None, is_bold=None, is_italic=None, - linked_style=None): - inst = GSInstance() - inst.name = name - if weight is not None: - # Glyphs 2.3 stores the instance weight in two to three places: - # 1. as a textual weightClass (such as “Bold”; no value defaults to - # "Regular"); - # 2. (optional) as numeric customParameters.weightClass (such as 700), - # which corresponds to OS/2.usWeightClass where 100 means Thin, - # 400 means Regular, 700 means Bold, and 900 means Black; - # 3. as numeric interpolationWeight (such as 66.0), which typically is - # the stem width but can be anything that works for interpolation - # (no value defaults to 100). - weightName, weightClass, interpolationWeight = weight - if weightName is not None: - inst.weightClass = weightName - if weightClass is not None: - inst.customParameters["weightClass"] = weightClass - if interpolationWeight is not None: - inst.interpolationWeight = interpolationWeight - if width is not None: - # Glyphs 2.3 stores the instance width in two places: - # 1. as a textual widthClass (such as “Condensed”; no value defaults - # to "Medium (normal)"); - # 2. as numeric interpolationWidth (such as 79), which typically is - # a percentage of whatever the font designer considers “normal” - # but can be anything that works for interpolation (no value - # defaults to 100). - widthClass, interpolationWidth = width - if widthClass is not None: - inst.widthClass = widthClass - if interpolationWidth is not None: - inst.interpolationWidth = interpolationWidth - # TODO: Support custom axes; need to triple-check how these are encoded in - # Glyphs files. Glyphs 3 will likely overhaul the representation of axes. - if is_bold is not None: - inst.isBold = is_bold - if is_italic is not None: - inst.isItalic = is_italic - if linked_style is not None: - inst.linkStyle = linked_style - return inst - - -class DesignspaceTest(unittest.TestCase): - def build_designspace(self, masters, instances): - master_dir = tempfile.mkdtemp() - try: - designspace, _ = build_designspace( - masters, master_dir, os.path.join(master_dir, "out"), instances) - with open(designspace, mode="r", encoding="utf-8") as f: - result = f.readlines() - finally: - shutil.rmtree(master_dir) - return result - - def expect_designspace(self, masters, instances, expectedFile): - actual = self.build_designspace(masters, instances) - path, _ = os.path.split(__file__) - expectedPath = os.path.join(path, "data", expectedFile) - with open(expectedPath, mode="r", encoding="utf-8") as f: - expected = f.readlines() - if actual != expected: - for line in difflib.unified_diff( - expected, actual, - fromfile=expectedPath, tofile=""): - sys.stderr.write(line) - self.fail("*.designspace file is different from expected") - - def test_basic(self): - masters, instances = makeFamily("DesignspaceTest Basic") - self.expect_designspace(masters, instances, - "DesignspaceTestBasic.designspace") - - def test_inactive_from_exports(self): - # Glyphs.app recognizes exports=0 as a flag for inactive instances. - # https://github.com/googlei18n/glyphsLib/issues/129 - masters, instances = makeFamily("DesignspaceTest Inactive") - for inst in instances["data"]: - if inst.name != "Semibold": - inst.exports = False - self.expect_designspace(masters, instances, - "DesignspaceTestInactive.designspace") - - def test_familyName(self): - masters, instances = makeFamily("DesignspaceTest FamilyName") - customFamily = makeInstance("Regular", weight=("Bold", 600, 151)) - customFamily.customParameters["familyName"] = "Custom Family" - instances["data"] = [ - makeInstance("Regular", weight=("Regular", 400, 90)), - customFamily, - ] - self.expect_designspace(masters, instances, - "DesignspaceTestFamilyName.designspace") - - def test_fileName(self): - masters, instances = makeFamily("DesignspaceTest FamilyName") - customFileName= makeInstance("Regular", weight=("Bold", 600, 151)) - customFileName.customParameters["fileName"] = "Custom FileName" - instances["data"] = [ - makeInstance("Regular", weight=("Regular", 400, 90)), - customFileName, - ] - self.expect_designspace(masters, instances, - "DesignspaceTestFileName.designspace") - - def test_noRegularMaster(self): - # Currently, fonttools.varLib fails to build variable fonts - # if the default axis value does not happen to be at the - # location of one of the interpolation masters. - # glyhpsLib tries to work around this downstream limitation. - masters = [ - makeMaster("NoRegularMaster", "Thin", weight=26), - makeMaster("NoRegularMaster", "Black", weight=190), - ] - instances = {"data": [ - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Bold", weight=("Thin", 100, 26)), - ]} - doc = etree.fromstringlist(self.build_designspace(masters, instances)) - weightAxis = doc.find('axes/axis[@tag="wght"]') - self.assertEqual(weightAxis.attrib["minimum"], "100.0") - self.assertEqual(weightAxis.attrib["default"], "100.0") # not 400 - self.assertEqual(weightAxis.attrib["maximum"], "900.0") - - def test_postscriptFontName(self): - master = makeMaster("PSNameTest", "Master") - thin, black = makeInstance("Thin"), makeInstance("Black") - instances = {"data": [thin, black]} - black.customParameters["postscriptFontName"] = "PSNameTest-Superfat" - d = etree.fromstringlist(self.build_designspace([master], instances)) - - def psname(doc, style): - inst = doc.find('instances/instance[@stylename="%s"]' % style) - return inst.attrib.get('postscriptfontname') - self.assertIsNone(psname(d, "Thin")) - self.assertEqual(psname(d, "Black"), "PSNameTest-Superfat") - - def test_instanceOrder(self): - # The generated *.designspace file should place instances - # in the same order as they appear in the original source. - # https://github.com/googlei18n/glyphsLib/issues/113 - masters, instances = makeFamily("DesignspaceTest InstanceOrder") - instances["data"] = [ - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Bold", weight=("Bold", 700, 151), is_bold=True), - ] - - self.expect_designspace(masters, instances, - "DesignspaceTestInstanceOrder.designspace") - - def test_twoAxes(self): - # In NotoSansArabic-MM.glyphs, the regular width only contains - # parameters for the weight axis. For the width axis, glyphsLib - # should use 100 as default value (just like Glyphs.app does). - familyName = "DesignspaceTest TwoAxes" - masters = [ - makeMaster(familyName, "Regular", weight=90), - makeMaster(familyName, "Black", weight=190), - makeMaster(familyName, "Thin", weight=26), - makeMaster(familyName, "ExtraCond", weight=90, width=70), - makeMaster(familyName, "ExtraCond Black", weight=190, width=70), - makeMaster(familyName, "ExtraCond Thin", weight=26, width=70), - ] - instances = { - "data": [ - makeInstance("Thin", weight=("Thin", 100, 26)), - makeInstance("Regular", weight=("Regular", 400, 90)), - makeInstance("Semibold", weight=("Semibold", 600, 128)), - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("ExtraCondensed Thin", - weight=("Thin", 100, 26), - width=("Extra Condensed", 70)), - makeInstance("ExtraCondensed", - weight=("Regular", 400, 90), - width=("Extra Condensed", 70)), - makeInstance("ExtraCondensed Black", - weight=("Black", 900, 190), - width=("Extra Condensed", 70)), - ] - } - self.expect_designspace(masters, instances, - "DesignspaceTestTwoAxes.designspace") - - def test_variationFontOrigin(self): - # Glyphs 2.4.1 introduced a custom parameter “Variation Font Origin” - # to specify which master should be considered the origin. - # https://glyphsapp.com/blog/glyphs-2-4-1-released - masters = [ - makeMaster("Family", "Thin", weight=26), - makeMaster("Family", "Regular", weight=100), - makeMaster("Family", "Medium", weight=111), - makeMaster("Family", "Black", weight=190), - ] - instances = { - "data": [ - makeInstance("Black", weight=("Black", 900, 190)), - makeInstance("Medium", weight=("Medium", 444, 111)), - makeInstance("Regular", weight=("Regular", 400, 100)), - makeInstance("Thin", weight=("Thin", 100, 26)), - ], - "Variation Font Origin": "Medium", - } - doc = etree.fromstringlist(self.build_designspace(masters, instances)) - medium = doc.find('sources/source[@stylename="Medium"]') - self.assertEqual(medium.find("lib").attrib["copy"], "1") - weightAxis = doc.find('axes/axis[@tag="wght"]') - self.assertEqual(weightAxis.attrib["default"], "444.0") - - def test_designspace_name(self): - master_dir = tempfile.mkdtemp() - try: - designspace_path, _ = build_designspace( - [ - makeMaster("Family Name", "Regular", weight=100), - makeMaster("Family Name", "Bold", weight=190), - ], master_dir, os.path.join(master_dir, "out"), {}) - # no shared base style name, only write the family name - self.assertEqual(os.path.basename(designspace_path), - "FamilyName.designspace") - - designspace_path, _ = build_designspace( - [ - makeMaster("Family Name", "Italic", weight=100), - makeMaster("Family Name", "Bold Italic", weight=190), - ], master_dir, os.path.join(master_dir, "out"), {}) - # 'Italic' is the base style; append to designspace name - self.assertEqual(os.path.basename(designspace_path), - "FamilyName-Italic.designspace") - finally: - shutil.rmtree(master_dir) - - -WEIGHT_CLASS_KEY = GLYPHS_PREFIX + "weightClass" -WIDTH_CLASS_KEY = GLYPHS_PREFIX + "widthClass" - - -class SetWeightWidthClassesTest(unittest.TestCase): - - def test_no_weigth_class(self): - ufo = defcon.Font() - # name here says "Bold", however no excplit weightClass - # is assigned - set_weight_class(ufo, makeInstance("Bold")) - # the default OS/2 weight class is set - self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") - - def test_weight_class(self): - ufo = defcon.Font() - data = makeInstance( - "Bold", - weight=("Bold", None, 150) - ) - - set_weight_class(ufo, data) - - self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") - - def test_explicit_default_weight(self): - ufo = defcon.Font() - data = makeInstance( - "Regular", - weight=("Regular", None, 100) - ) - - set_weight_class(ufo, data) - # the default OS/2 weight class is set - self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") - - def test_no_width_class(self): - ufo = defcon.Font() - # no explicit widthClass set, instance name doesn't matter - set_width_class(ufo, makeInstance("Normal")) - # the default OS/2 width class is set - self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") - - def test_width_class(self): - ufo = defcon.Font() - data = makeInstance( - "Condensed", - width=("Condensed", 80) - ) - - set_width_class(ufo, data) - - self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") - - def test_explicit_default_width(self): - ufo = defcon.Font() - data = makeInstance( - "Regular", - width=("Medium (normal)", 100) - ) - - set_width_class(ufo, data) - # the default OS/2 width class is set - self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) - # non-empty value is stored in the UFO lib even if same as default - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") - - def test_weight_and_width_class(self): - ufo = defcon.Font() - data = makeInstance( - "SemiCondensed ExtraBold", - weight=("ExtraBold", None, 160), - width=("SemiCondensed", 90) - ) - - set_weight_class(ufo, data) - set_width_class(ufo, data) - - self.assertEqual(ufo.info.openTypeOS2WeightClass, 800) - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "ExtraBold") - self.assertEqual(ufo.info.openTypeOS2WidthClass, 4) - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") - - def test_unknown_weight_class(self): - ufo = defcon.Font() - # "DemiLight" is not among the predefined weight classes listed in - # Glyphs.app/Contents/Frameworks/GlyphsCore.framework/Versions/A/ - # Resources/weights.plist - # NOTE It is not possible from the user interface to set a custom - # string as instance 'weightClass' since the choice is constrained - # by a drop-down menu. - data = makeInstance( - "DemiLight Italic", - weight=("DemiLight", 350, 70) - ) - - set_weight_class(ufo, data) - - # we do not set any OS/2 weight class; user needs to provide - # a 'weightClass' custom parameter in this special case - self.assertTrue(ufo.info.openTypeOS2WeightClass is None) - - -if __name__ == "__main__": - sys.exit(unittest.main()) diff --git a/tests/writer_test.py b/tests/writer_test.py index f9ec57070..dc74e659b 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -419,7 +419,7 @@ def test_write_instance(self): { customParameters = ( { - name = famiyName; + name = familyName; value = "Sans Rien (familyName)"; }, { From 382493963bbf297a01fec26b748274319247ea2c Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 1 Feb 2018 19:19:56 +0000 Subject: [PATCH 21/44] Handle master names like latest Glyphs (1114) --- Lib/glyphsLib/classes.py | 55 +++++++++++++++++--------- tests/builder/builder_test.py | 2 +- tests/classes_test.py | 58 +++++++++++++++++++++++++++- tests/data/GlyphsUnitTestSans.glyphs | 9 ++--- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 12bdc3dda..e99ec6faa 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -1205,6 +1205,10 @@ 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, @@ -1242,7 +1246,7 @@ class GSFontMaster(GSBase): # and weight anymore as attributes, even though those properties are # still written to the saved files. "weight": "Regular", - "width": "Medium (normal)", + "width": "Regular", "weightValue": 100.0, "widthValue": 100.0, "customValue": 0.0, @@ -1308,17 +1312,14 @@ def __repr__(self): (self.name, self.widthValue, self.weightValue) def shouldWriteValueForKey(self, key): - if key == "width": - return getattr(self, key) != "Medium (normal)" - if key == "weight": + 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 + # Only write out the name if we can't make it by joining the parts + return self.name != self._joinName() return super(GSFontMaster, self).shouldWriteValueForKey(key) @property @@ -1334,30 +1335,48 @@ def name(self): def name(self, value): # This is what Glyphs 1113 seems to be doing, approximately. self.weight, self.width, self.customName = self._splitName(value) + # Only store the requested name if we can't build it from the parts + if self._joinName() == value: + self._name = None + else: + self._name = value def _joinName(self): names = [self.weight, self.width, self.customName] names = [n for n in names if n] # Remove None and empty string - if len(names) > 1 and "Medium (normal)" in names: - names.remove("Medium (normal)") if len(names) > 1 and "Regular" in names: names.remove("Regular") - if abs(self.italicAngle) > 0.01: - names.append("Italic") + # if abs(self.italicAngle) > 0.01: + # names.append("Italic") return " ".join(list(names)) def _splitName(self, value): if value is None: value = '' weight = 'Regular' - width = 'Medium (normal)' + width = 'Regular' custom = '' - names = value.split(" ") - if names and names[0] in WEIGHT_CODES: - weight = names.pop(0) - if names and names[0] in WIDTH_CODES: - witdh = names.pop(0) - custom = " ".join(names) + 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( diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index a3183fe64..70f148e60 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -764,7 +764,7 @@ def test_lib_weight(self): def test_lib_no_width(self): font = generate_minimal_font() ufo = to_ufos(font)[0] - self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'width'], 'Medium (normal)') + self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'width'], 'Regular') def test_lib_width(self): font = generate_minimal_font() diff --git a/tests/classes_test.py b/tests/classes_test.py index d432cd9fd..e28e7b7f4 100755 --- a/tests/classes_test.py +++ b/tests/classes_test.py @@ -563,7 +563,9 @@ 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' @@ -573,6 +575,60 @@ def test_name(self): master.customName = '' self.assertEqual('Light', master.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) 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"; From 6b8da79fce4a96491923b1a12b1a1a294d1b24e3 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Fri, 2 Feb 2018 11:49:53 +0000 Subject: [PATCH 22/44] Require all UFOs to have the same family name --- Lib/glyphsLib/builder/axes.py | 6 + Lib/glyphsLib/builder/font.py | 8 +- Lib/glyphsLib/builder/names.py | 10 +- tests/builder/designspace_gen_test.py | 112 ++++++++++++++++++ .../data/DesignspaceGenTestItalic.designspace | 44 +++++++ .../DesignspaceGenTestRegular.designspace | 44 +++++++ 6 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 tests/builder/designspace_gen_test.py create mode 100644 tests/data/DesignspaceGenTestItalic.designspace create mode 100644 tests/data/DesignspaceGenTestRegular.designspace diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py index ac59ce4c6..991a76ee2 100644 --- a/Lib/glyphsLib/builder/axes.py +++ b/Lib/glyphsLib/builder/axes.py @@ -361,6 +361,12 @@ def get_regular_master(font): 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] diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index 8c27063f6..f6da59d6d 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -109,8 +109,7 @@ def to_glyphs_font_attributes(self, source, master, is_initial): if is_initial: _set_glyphs_font_attributes(self, source) else: - # self._compare_and_merge_glyphs_font_attributes(ufo) - pass + _compare_and_merge_glyphs_font_attributes(self, source) def _set_glyphs_font_attributes(self, source): @@ -150,6 +149,11 @@ def _set_glyphs_font_attributes(self, source): self.to_glyphs_features(ufo) +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).""" diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index 9a0280987..f9ea06ef9 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -99,8 +99,14 @@ def _get_linked_style(style_name, is_bold, is_italic): return ' '.join(linked_style) -def to_glyphs_family_names(self, ufo): - self.font.familyName = ufo.info.familyName +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): diff --git a/tests/builder/designspace_gen_test.py b/tests/builder/designspace_gen_test.py new file mode 100644 index 000000000..15a68dd8d --- /dev/null +++ b/tests/builder/designspace_gen_test.py @@ -0,0 +1,112 @@ +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/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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7f9e8e6bfea8844dfebf556bd27c1ee88f949dce Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Fri, 2 Feb 2018 20:54:16 +0000 Subject: [PATCH 23/44] Cleanup the interpolation code a bit --- Lib/glyphsLib/__init__.py | 24 +- Lib/glyphsLib/builder/builders.py | 21 +- Lib/glyphsLib/builder/instances.py | 10 +- Lib/glyphsLib/builder/interpolation.py | 394 --------- Lib/glyphsLib/interpolation.py | 112 +++ tests/builder/designspace_gen_test.py | 18 + tests/builder/interpolation_test.py | 261 +++--- tests/data/MontserratStrippedDown.glyphs | 1010 ++++++++++++++++++++++ 8 files changed, 1313 insertions(+), 537 deletions(-) delete mode 100644 Lib/glyphsLib/builder/interpolation.py create mode 100644 Lib/glyphsLib/interpolation.py create mode 100644 tests/data/MontserratStrippedDown.glyphs diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index b220b6910..b425693b1 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -24,10 +24,9 @@ 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.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.util import clean_ufo __version__ = "2.2.2.dev0" @@ -75,16 +74,23 @@ 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) + designspace = to_designspace( + font, family_name=family_name, propagate_anchors=propagate_anchors) + 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) + designspace_path = os.path.join(master_dir, designspace.filename) + designspace.write(designspace_path) + # All the instance data should be in the designspace + instance_data = designspace.instances return ufos, designspace_path, instance_data else: - for ufo in ufos: - write_ufo(ufo, master_dir) return ufos diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index fa032180b..85040b7dd 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -105,22 +105,15 @@ 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 @property def masters(self): @@ -260,8 +253,7 @@ 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)) + filter_instances_by_family(instances, self.family_name)) instance_data = {'data': instances} first_ufo = next(iter(self.masters)) @@ -306,14 +298,7 @@ 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): diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index da19e4c67..489f97fed 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -37,7 +37,9 @@ def to_designspace_instances(self): """Write instance data from self.font to self.designspace.""" for instance in self.font.instances: - if is_instance_active(instance) or self.minimize_glyphs_diffs: + if (self.minimize_glyphs_diffs or + (is_instance_active(instance) and + _is_instance_included_in_family(self, instance))): _to_designspace_instance(self, instance) @@ -108,6 +110,12 @@ def _to_designspace_instance(self, instance): 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 diff --git a/Lib/glyphsLib/builder/interpolation.py b/Lib/glyphsLib/builder/interpolation.py deleted file mode 100644 index 234970cf1..000000000 --- a/Lib/glyphsLib/builder/interpolation.py +++ /dev/null @@ -1,394 +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. - -from __future__ import (print_function, division, absolute_import, - unicode_literals) - -from collections import OrderedDict, namedtuple -import logging -import os -import xml.etree.ElementTree as etree - -from .builders import UFOBuilder -from .custom_params import to_ufo_custom_params -from .names import build_stylemap_names -from .constants import GLYPHS_PREFIX - -from glyphsLib.util import build_ufo_path, write_ufo, clean_ufo - -__all__ = [ - 'interpolate', 'build_designspace', 'apply_instance_data' -] - -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. -# FIXME: (jany) This behaviour should be in classes.py -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, -} - - -def interpolate(ufos, master_dir, out_dir, instance_data, round_geometry=True): - """Create MutatorMath designspace and generate instances. - Returns instance UFOs. - """ - from mutatorMath.ufo import build - - designspace_path, instance_files = build_designspace( - ufos, master_dir, out_dir, instance_data) - - logger.info('Building instances') - for path, _ in instance_files: - clean_ufo(path) - build(designspace_path, outputUFOFormatVersion=3, - roundGeometry=round_geometry) - - instance_ufos = apply_instance_data(instance_files) - return instance_ufos - - -def build_designspace(masters, master_dir, out_dir, instance_data): - """Just create MutatorMath designspace without generating instances. - - Returns the path of the resulting designspace document and a list of - (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' - - for font in masters: - write_ufo(font, master_dir) - - # needed so that added masters and instances have correct relative paths - tmp_path = os.path.join(master_dir, 'tmp.designspace') - writer = DesignSpaceDocumentWriter(tmp_path) - - instances = list(filter(is_instance_active, instance_data.get('data', []))) - 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, - # FIXME: (jany) must provide a postscriptFontName or else cannot - # read back the instance element using DesignspaceDocumentReader - # becauses its .instances are in dictionary - # {postscriptFontName: instance} - # postScriptFontName=postScriptFontName or familyName + 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) - - -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. - """ - import defcon - - instance_ufos = [] - for path, data in instance_data: - ufo = defcon.Font(path) - set_weight_class(ufo, data) - set_width_class(ufo, data) - self = UFOBuilder(instance_data, defcon) - # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? - to_ufo_custom_params(self, ufo, data) - ufo.save() - instance_ufos.append(ufo) - return instance_ufos - - diff --git a/Lib/glyphsLib/interpolation.py b/Lib/glyphsLib/interpolation.py new file mode 100644 index 000000000..cfd3ccf08 --- /dev/null +++ b/Lib/glyphsLib/interpolation.py @@ -0,0 +1,112 @@ +# 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, namedtuple +import logging +import os +import xml.etree.ElementTree as etree + +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.classes import WEIGHT_CODES, WIDTH_CODES + +from glyphsLib.util import build_ufo_path, write_ufo, clean_ufo + +__all__ = [ + 'interpolate', 'build_designspace', 'apply_instance_data' +] + +logger = logging.getLogger(__name__) + + +def interpolate(ufos, master_dir, out_dir, instance_data, round_geometry=True): + """Create MutatorMath designspace and generate instances. + Returns instance UFOs. + """ + # TODO: (jany) This should not be in glyphsLib, but rather an instance + # method of the designspace document, or another thing like + # ufoProcessor. + # GlyphsLib should put all that is necessary to interpolate into the + # InstanceDescriptor (lib if needed) + # All the logic like applying custom parameters and so on should be made + # glyphs-agnostic (because most of it should be relevant for other build + # systems as well?) + # or the logic that is really specific to Glyphs could be implemented as + # in apply_instance_data: InstanceDescriptor -> UFO of the instance. + raise NotImplementedError + + +def build_designspace(masters, master_dir, out_dir, instance_data): + """Just create MutatorMath designspace without generating instances. + + Returns the path of the resulting designspace document and a list of + (instance_path, instance_data) tuples which map instance UFO filenames to + Glyphs data for that instance. + """ + # TODO: (jany) check whether this function is still useful + raise NotImplementedError + + +def _set_class_from_instance(ufo, data, key, codes): + class_name = getattr(data, key) + if class_name: + ufo.lib[GLYPHS_PREFIX + key + "Class"] = class_name + if class_name in codes: + class_code = codes[class_name] + ufo_key = "".join(['openTypeOS2', key[0].upper(), key[1:], 'Class']) + setattr(ufo.info, ufo_key, class_code) + + +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, "weight", 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, "width", 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. + """ + # FIXME: (jany) This is implemented because fontmake calls it. + # The instance_data will be an array of InstanceDescriptors + import defcon + + instance_ufos = [] + for path, data in instance_data: + ufo = defcon.Font(path) + set_weight_class(ufo, data) + set_width_class(ufo, data) + self = UFOBuilder(instance_data, defcon) + # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? + to_ufo_custom_params(self, ufo, data) + ufo.save() + instance_ufos.append(ufo) + return instance_ufos diff --git a/tests/builder/designspace_gen_test.py b/tests/builder/designspace_gen_test.py index 15a68dd8d..3962e87e4 100644 --- a/tests/builder/designspace_gen_test.py +++ b/tests/builder/designspace_gen_test.py @@ -1,3 +1,21 @@ +# 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 diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py index 37003df59..7383eeba8 100644 --- a/tests/builder/interpolation_test.py +++ b/tests/builder/interpolation_test.py @@ -24,12 +24,10 @@ import unittest import xml.etree.ElementTree as etree -# import defcon +import defcon from fontTools.misc.py23 import open from glyphsLib.builder.constants import GLYPHS_PREFIX -# from glyphsLib.builder.interpolation import ( -# build_designspace, set_weight_class, set_width_class, build_stylemap_names -# ) +from glyphsLib.interpolation import set_weight_class, set_width_class from glyphsLib.classes import GSFont, GSFontMaster, GSInstance from glyphsLib import to_designspace, to_glyphs @@ -188,6 +186,11 @@ def test_inactive_from_exports(self): 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)) @@ -348,6 +351,145 @@ def test_designspace_name(self): # '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 + 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", 3, 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)", 5, 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", 4, 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) + # def test_designspace_roundtrip(tmpdir): # return @@ -443,117 +585,6 @@ def test_designspace_name(self): # rtxml = fp.read() # # assert xml == rtxml -# -# -# WEIGHT_CLASS_KEY = GLYPHS_PREFIX + "weightClass" -# WIDTH_CLASS_KEY = GLYPHS_PREFIX + "widthClass" -# -# -# class SetWeightWidthClassesTest(unittest.TestCase): -# -# def test_no_weigth_class(self): -# ufo = defcon.Font() -# # name here says "Bold", however no excplit weightClass -# # is assigned -# set_weight_class(ufo, makeInstance("Bold")) -# # the default OS/2 weight class is set -# self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) -# # non-empty value is stored in the UFO lib even if same as default -# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") -# -# def test_weight_class(self): -# ufo = defcon.Font() -# data = makeInstance( -# "Bold", -# weight=("Bold", None, 150) -# ) -# -# set_weight_class(ufo, data) -# -# self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) -# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") -# -# def test_explicit_default_weight(self): -# ufo = defcon.Font() -# data = makeInstance( -# "Regular", -# weight=("Regular", None, 100) -# ) -# -# set_weight_class(ufo, data) -# # the default OS/2 weight class is set -# self.assertEqual(ufo.info.openTypeOS2WeightClass, 400) -# # non-empty value is stored in the UFO lib even if same as default -# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") -# -# def test_no_width_class(self): -# ufo = defcon.Font() -# # no explicit widthClass set, instance name doesn't matter -# set_width_class(ufo, makeInstance("Normal")) -# # the default OS/2 width class is set -# self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) -# # non-empty value is stored in the UFO lib even if same as default -# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") -# -# def test_width_class(self): -# ufo = defcon.Font() -# data = makeInstance( -# "Condensed", -# width=("Condensed", 80) -# ) -# -# set_width_class(ufo, data) -# -# self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) -# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") -# -# def test_explicit_default_width(self): -# ufo = defcon.Font() -# data = makeInstance( -# "Regular", -# width=("Medium (normal)", 100) -# ) -# -# set_width_class(ufo, data) -# # the default OS/2 width class is set -# self.assertEqual(ufo.info.openTypeOS2WidthClass, 5) -# # non-empty value is stored in the UFO lib even if same as default -# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") -# -# def test_weight_and_width_class(self): -# ufo = defcon.Font() -# data = makeInstance( -# "SemiCondensed ExtraBold", -# weight=("ExtraBold", None, 160), -# width=("SemiCondensed", 90) -# ) -# -# set_weight_class(ufo, data) -# set_width_class(ufo, data) -# -# self.assertEqual(ufo.info.openTypeOS2WeightClass, 800) -# self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "ExtraBold") -# self.assertEqual(ufo.info.openTypeOS2WidthClass, 4) -# self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") -# -# def test_unknown_weight_class(self): -# ufo = defcon.Font() -# # "DemiLight" is not among the predefined weight classes listed in -# # Glyphs.app/Contents/Frameworks/GlyphsCore.framework/Versions/A/ -# # Resources/weights.plist -# # NOTE It is not possible from the user interface to set a custom -# # string as instance 'weightClass' since the choice is constrained -# # by a drop-down menu. -# data = makeInstance( -# "DemiLight Italic", -# weight=("DemiLight", 350, 70) -# ) -# -# set_weight_class(ufo, data) -# -# # we do not set any OS/2 weight class; user needs to provide -# # a 'weightClass' custom parameter in this special case -# self.assertTrue(ufo.info.openTypeOS2WeightClass is None) if __name__ == "__main__": 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; +} From 663f3e3b19153e4004ef024675a11553e2604e02 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 12 Feb 2018 11:18:49 +0000 Subject: [PATCH 24/44] Use the upstream feature parser with followIncludes --- Lib/glyphsLib/builder/features.py | 113 +----------------------------- setup.py | 2 +- 2 files changed, 3 insertions(+), 112 deletions(-) diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index 73b12d9d6..ecb322c30 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -21,10 +21,7 @@ from fontTools.misc.py23 import round, unicode from fontTools.misc.py23 import StringIO -from fontTools.feaLib.lexer import Lexer -from fontTools.feaLib.parser import Parser, SymbolTable -from fontTools.feaLib.error import FeatureLibError -from fontTools.feaLib import parser, ast +from fontTools.feaLib import ast, parser import re @@ -173,117 +170,11 @@ def to_glyphs_features(self, ufo): processor.to_glyphs(self.font) -# TODO: (jany) backport to feaLib -class IncludeStatement(ast.Statement): - def __init__(self, location, filename): - super(IncludeStatement, self).__init__(location) - self.filename = filename - - def build(self): - # TODO: (jany) check that raising makes sense here - # TODO: (jany) use the correct exception class - raise FeatureLibError( - "It does not make sense to build an include statement, " - "use the including parser for building", - self.location) - - def asFea(self, indent=""): - return indent + "include(%s);" % self.filename - - -# TODO: (jany) backport to feaLib -class NonIncludingParser(Parser): - def __init__(self, featurefile, glyphMap): - self.glyphMap_ = glyphMap - self.doc_ = self.ast.FeatureFile() - self.anchors_ = SymbolTable() - self.glyphclasses_ = SymbolTable() - self.lookups_ = SymbolTable() - self.valuerecords_ = SymbolTable() - self.symbol_tables_ = { - self.anchors_, self.valuerecords_ - } - self.next_token_type_, self.next_token_ = (None, None) - self.cur_comments_ = [] - self.next_token_location_ = None - # Diff: simple Lexer - filename = None - if hasattr(featurefile, "read"): - fileobj, closing = featurefile, False - else: - filename, closing = featurefile, True - # try: - fileobj = open(filename, "r", encoding="utf-8") - # except IOError as err: - # raise feaLib.FeatureLibError(str(err), location) - data = fileobj.read() - if not filename: - filename = fileobj.name if hasattr(fileobj, "name") else "" - if closing: - fileobj.close() - self.lexer_ = Lexer(data, filename) - self.advance_lexer_(comments=True) - - def parse(self): - statements = self.doc_.statements - # Diff: or self.cur_comments_ - while self.next_token_type_ is not None or self.cur_comments_: - self.advance_lexer_(comments=True) - if self.cur_token_type_ is Lexer.COMMENT: - statements.append(self.ast.Comment(self.cur_token_location_, self.cur_token_)) - elif self.cur_token_type_ is Lexer.GLYPHCLASS: - statements.append(self.parse_glyphclass_definition_()) - # Diff: return "include" statements as-is - elif self.is_cur_keyword_("include"): - statements.append(self.parse_include_()) - elif self.is_cur_keyword_(("anon", "anonymous")): - statements.append(self.parse_anonymous_()) - elif self.is_cur_keyword_("anchorDef"): - statements.append(self.parse_anchordef_()) - elif self.is_cur_keyword_("languagesystem"): - statements.append(self.parse_languagesystem_()) - elif self.is_cur_keyword_("lookup"): - statements.append(self.parse_lookup_(vertical=False)) - elif self.is_cur_keyword_("markClass"): - statements.append(self.parse_markClass_()) - elif self.is_cur_keyword_("feature"): - statements.append(self.parse_feature_block_()) - elif self.is_cur_keyword_("table"): - statements.append(self.parse_table_()) - elif self.is_cur_keyword_("valueRecordDef"): - statements.append( - self.parse_valuerecord_definition_(vertical=False)) - elif self.cur_token_type_ is Lexer.NAME and self.cur_token_ in self.extensions: - statements.append(self.extensions[self.cur_token_](self)) - elif self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == ";": - continue - else: - raise FeatureLibError( - "Expected feature, languagesystem, lookup, markClass, " - "table, or glyph class definition, got {} \"{}\"".format(self.cur_token_type_, self.cur_token_), - self.cur_token_location_) - return self.doc_ - - def parse_include_(self): - assert self.cur_token_ == "include" - location = self.cur_token_location_ - filename = self.expect_filename_() - # self.expect_symbol_(";") - return IncludeStatement(location, filename) - - def expect_filename_(self): - self.advance_lexer_() - if self.cur_token_type_ is not Lexer.FILENAME: - raise FeatureLibError("Expected file name", - self.cur_token_location_) - return self.cur_token_ - - class FeaDocument(object): """Parse the string of a fea code into statements.""" def __init__(self, text, glyph_set): feature_file = StringIO(text) - parser_ = NonIncludingParser(feature_file, glyph_set) + 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 diff --git a/setup.py b/setup.py index 31bd57083..6cf7a49ff 100644 --- a/setup.py +++ b/setup.py @@ -180,7 +180,7 @@ def run(self): setup_requires=pytest_runner + wheel + bump2version, tests_require=test_requires, install_requires=[ - "fonttools>=3.4.0", + "fonttools>=3.22.0", "defcon>=0.3.0", "MutatorMath>=2.0.4", ], From fe82a0673730e6f0956e330f137891100fb9948f Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Tue, 13 Feb 2018 10:40:27 +0000 Subject: [PATCH 25/44] Fix empty fsType glyphs->ufo --- Lib/glyphsLib/__init__.py | 1 + Lib/glyphsLib/builder/custom_params.py | 24 +++++++++++++++++++----- tests/builder/custom_params_test.py | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index b425693b1..24bbc1878 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -17,6 +17,7 @@ unicode_literals) from io import open +import os import logging from fontTools.misc.py23 import tostr diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 047650b7c..4161b4c39 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -170,14 +170,14 @@ def __init__(self, glyphs_name, ufo_name=None, # - 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 or ufo_value == []: + 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 or glyphs_value == []: + if glyphs_value is None: return ufo_value = self.value_to_ufo(glyphs_value) self._write_to_ufo(glyphs, ufo, ufo_value) @@ -306,8 +306,8 @@ def register(handler): 'postscriptUniqueID', # Should this be handled in `blue_values.py`? - 'postscriptFamilyBlues', - 'postscriptFamilyOtherBlues', + # 'postscriptFamilyBlues', + # 'postscriptFamilyOtherBlues', 'postscriptBlueFuzz', 'postscriptForceBold', @@ -321,7 +321,21 @@ def register(handler): 'macintoshFONDName', ) for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: - register(ParamHandler(name, name)) + register(ParamHandler(name)) + + +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( diff --git a/tests/builder/custom_params_test.py b/tests/builder/custom_params_test.py index 74e2733b7..be51e8a04 100644 --- a/tests/builder/custom_params_test.py +++ b/tests/builder/custom_params_test.py @@ -243,4 +243,19 @@ def test_useProductionNames(self): 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, []) From 898219632a755d968e261ab860c281d4abde000d Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 15 Feb 2018 18:55:30 +0000 Subject: [PATCH 26/44] Read instance data from the designspace --- Lib/glyphsLib/__init__.py | 9 +- Lib/glyphsLib/builder/axes.py | 206 +++++++++++++++---------- Lib/glyphsLib/builder/builders.py | 9 +- Lib/glyphsLib/builder/custom_params.py | 1 + Lib/glyphsLib/builder/instances.py | 120 +++++++++++++- Lib/glyphsLib/classes.py | 2 +- Lib/glyphsLib/interpolation.py | 53 +------ tests/builder/interpolation_test.py | 85 ++++++---- tests/writer_test.py | 7 + 9 files changed, 322 insertions(+), 170 deletions(-) diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index 24bbc1878..d0d4ef1bb 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -77,7 +77,8 @@ def build_masters(filename, master_dir, designspace_instance_dir=None, font = GSFont(filename) designspace = to_designspace( - font, family_name=family_name, propagate_anchors=propagate_anchors) + font, family_name=family_name, propagate_anchors=propagate_anchors, + instance_dir=designspace_instance_dir) ufos = [] for source in designspace.sources: ufos.append(source.font) @@ -88,9 +89,9 @@ def build_masters(filename, master_dir, designspace_instance_dir=None, if designspace_instance_dir is not None: designspace_path = os.path.join(master_dir, designspace.filename) designspace.write(designspace_path) - # All the instance data should be in the designspace - instance_data = designspace.instances - return ufos, designspace_path, instance_data + # All the instance data should be in the designspace. That's why for + # now we return the designspace in place of `instance_data`. + return ufos, designspace_path, designspace else: return ufos diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py index 991a76ee2..c3bed9aec 100644 --- a/Lib/glyphsLib/builder/axes.py +++ b/Lib/glyphsLib/builder/axes.py @@ -25,41 +25,20 @@ # This is a key into GSFont.userData to store axes defined in the designspace AXES_KEY = GLYPHLIB_PREFIX + 'axes' -# From the spec: https://www.microsoft.com/typography/otspec/os2.htm#wtc -CLASSES_DICT = { - 'wght': { - 100: ('Thin', 100), - 200: ('Extra-light', 200), - 300: ('Light', 300), - 400: ('Regular', 400), - 500: ('Medium', 500), - 600: ('Semi-bold', 600), - 700: ('Bold', 700), - 800: ('Extra-bold', 800), - 900: ('Black', 900), - }, - 'wdth': { - 1: ('Ultra-condensed', 50), - 2: ('Extra-condensed', 62.5), - 3: ('Condensed', 75), - 4: ('Semi-condensed', 87.5), - 5: ('Medium', 100), - 6: ('Semi-expanded', 112.5), - 7: ('Expanded', 125), - 8: ('Extra-expanded', 150), - 9: ('Ultra-expanded', 200), - } +# 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_name(axis, ufo_class): - """ - >>> class_to_name('wdth', 7) - 'Expanded' - """ - return CLASSES_DICT[axis][int(ufo_class)][0] - - def class_to_value(axis, ufo_class): """ >>> class_to_value('wdth', 7) @@ -68,40 +47,76 @@ def class_to_value(axis, ufo_class): if axis == 'wght': # 600.0 => 600, 250 => 250 return int(ufo_class) - return CLASSES_DICT[axis][int(ufo_class)][1] + 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_code_to_value(axis_tag, user_loc): - """ Go from Glyphs UI strings to user space location. - >>> user_loc_code_to_value('wght', 'ExtraLight') +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_code_to_value('wdth', 'SemiCondensed') + >>> 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': - return class_to_value('wght', WEIGHT_CODES.get(user_loc, user_loc)) - if axis_tag == 'wdth': - return class_to_value('wdth', WIDTH_CODES.get(user_loc, user_loc)) + 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) - return min(sorted(CLASSES_DICT[axis_tag].items()), - key=lambda item: abs(item[1][1] - user_loc))[0] + 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_code(axis_tag, user_loc): - """ - >>> user_loc_value_to_code('wdth', 150) + +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 = {} @@ -128,9 +143,6 @@ def to_designspace_axes(self): axis.tag = axis_def.tag axis.name = axis_def.name - regularDesignLoc = axis_def.get_design_loc(regular_master) - regularUserLoc = axis_def.get_user_loc(regular_master) - axis.labelNames = {"en": axis_def.name} instance_mapping = [] for instance in self.font.instances: @@ -138,24 +150,26 @@ def to_designspace_axes(self): designLoc = axis_def.get_design_loc(instance) userLoc = axis_def.get_user_loc(instance) instance_mapping.append((userLoc, designLoc)) - - # FIXME: (jany) why the next two lines? - if designLoc == regularDesignLoc: - regularUserLoc = userLoc instance_mapping = sorted(set(instance_mapping)) # avoid duplicates master_mapping = [] for master in self.font.masters: designLoc = axis_def.get_design_loc(master) - # FIXME: (jany) in latest Glyphs (1113) masters don't have - # a user loc - userLoc = axis_def.get_user_loc(master) + # Glyphs masters don't have a user location + userLoc = designLoc master_mapping.append((userLoc, designLoc)) master_mapping = sorted(set(master_mapping)) - minimum = maximum = default = axis_def.default_user_loc # Prefer the instance-based mapping mapping = instance_mapping or master_mapping + + 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]) @@ -233,6 +247,10 @@ def to_glyphs_axes(self): 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 @@ -243,65 +261,93 @@ def __init__(self, tag, name, design_loc_key, default_design_loc=0.0, self.user_loc_param = user_loc_param self.default_user_loc = default_user_loc - def get_design_loc(self, master_or_instance): - return getattr(master_or_instance, self.design_loc_key) + def 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, master_or_instance): + 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... FIXME: clarify what it is for the width. + """ + assert isinstance(instance, classes.GSInstance) if self.tag == 'wdth': # FIXME: (jany) existing test "DesignspaceTestTwoAxes.designspace" # suggests that the user location is the same as the design loc # for the width only - return self.get_design_loc(master_or_instance) + return self.get_design_loc(instance) user_loc = self.default_user_loc if self.user_loc_key is not None: - user_loc = getattr(master_or_instance, self.user_loc_key) - user_loc = user_loc_code_to_value(self.tag, user_loc) + # 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_ = master_or_instance.customParameters[self.user_loc_param] + 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, master_or_instance, value): + 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_code(self.tag, value) - value_for_code = user_loc_code_to_value(self.tag, code) + 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(master_or_instance, self.user_loc_key, code) + 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) - master_or_instance.customParameters[self.user_loc_param] = class_ + instance.customParameters[self.user_loc_param] = class_ except: pass - def set_user_loc_code(self, master_or_instance, code): + 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(master_or_instance, self.user_loc_key, code) - - -DEFAULT_AXES_DEFS = ( - AxisDefinition('wght', 'Weight', 'weightValue', 100.0, - 'weight', 'weightClass', 400.0), - AxisDefinition('wdth', 'Width', 'widthValue', 100.0, - 'width', 'widthClass', 100.0), - AxisDefinition('XXXX', 'Custom', 'customValue', 0.0, None, None, 0.0), -) + 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 diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 85040b7dd..f8853baf6 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -27,7 +27,8 @@ from glyphsLib import classes, glyphdata_generated from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX -from .axes import DEFAULT_AXES_DEFS, find_base_style, class_to_value +from .axes import (WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF, find_base_style, + class_to_value) GLYPH_ORDER_KEY = PUBLIC_PREFIX + 'glyphOrder' @@ -212,7 +213,7 @@ def _layer_order_in_glyph(self, layer): # TODO: move to layers.py # TODO: optimize? for order, glyph_layer in enumerate(layer.parent.layers.values()): - if glyph_layer == layer: + if glyph_layer is layer: return order return None @@ -422,8 +423,8 @@ def _fake_designspace(self, ufos): # Make weight and width axis if relevant for info_key, axis_def in zip( - ('openTypeOS2WeightClass', 'openTypeOS2WidthClass'), - DEFAULT_AXES_DEFS): + ('openTypeOS2WeightClass', 'openTypeOS2WidthClass'), + (WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF)): axis = designspace.newAxisDescriptor() axis.tag = axis_def.tag axis.name = axis_def.name diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 4161b4c39..f8c828393 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -25,6 +25,7 @@ CODEPAGE_RANGES, REVERSE_CODEPAGE_RANGES) from .features import replace_feature +# TODO: update this documentation """Set Glyphs custom parameters in UFO info or lib, where appropriate. Custom parameter data can be pre-parsed out of Glyphs data and provided via diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index 489f97fed..057c00fd9 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -19,12 +19,13 @@ import os from glyphsLib.util import build_ufo_path -from glyphsLib.classes import WEIGHT_CODES +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 +from .axes import (get_axis_definitions, is_instance_active, interp, + WEIGHT_AXIS_DEF, WIDTH_AXIS_DEF) EXPORT_KEY = GLYPHS_PREFIX + 'export' WIDTH_KEY = GLYPHS_PREFIX + 'width' @@ -32,6 +33,7 @@ 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): @@ -76,9 +78,12 @@ def _to_designspace_instance(self, instance): 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): - location[axis_def.name] = axis_def.get_design_loc(instance) + # 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? @@ -106,7 +111,27 @@ def _to_designspace_instance(self, instance): ufo_instance.lib[INSTANCE_INTERPOLATIONS_KEY] = instance.instanceInterpolations ufo_instance.lib[MANUAL_INTERPOLATION_KEY] = instance.manualInterpolation - # TODO: put the userData/customParameters in lib + # 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) @@ -232,9 +257,96 @@ def to_glyphs_instances(self): # 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) + + +def apply_instance_data(designspace): + """Open instances, apply data, and re-save. + + Args: + instance_data: DesignSpaceDocument object with some instances + Returns: + List of opened and updated instance UFOs. + """ + import defcon + + instance_ufos = [] + for instance in designspace.instances: + path = instance.path + ufo = defcon.Font(path) + set_weight_class(ufo, designspace, instance) + set_width_class(ufo, designspace, instance) + + glyphs_instance = InstanceDescriptorAsGSInstance(instance) + builder = UFOBuilder(instance, defcon) + # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? + to_ufo_custom_params(self, ufo, instance) + ufo.save() + instance_ufos.append(ufo) + return instance_ufos diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index e99ec6faa..1a055fb54 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -3013,7 +3013,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) diff --git a/Lib/glyphsLib/interpolation.py b/Lib/glyphsLib/interpolation.py index cfd3ccf08..e605f3ef7 100644 --- a/Lib/glyphsLib/interpolation.py +++ b/Lib/glyphsLib/interpolation.py @@ -24,7 +24,7 @@ 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.classes import WEIGHT_CODES, WIDTH_CODES +from glyphsLib.builder.instances import apply_instance_data from glyphsLib.util import build_ufo_path, write_ufo, clean_ufo @@ -41,7 +41,7 @@ def interpolate(ufos, master_dir, out_dir, instance_data, round_geometry=True): """ # TODO: (jany) This should not be in glyphsLib, but rather an instance # method of the designspace document, or another thing like - # ufoProcessor. + # ufoProcessor/mutatorMath.build() # GlyphsLib should put all that is necessary to interpolate into the # InstanceDescriptor (lib if needed) # All the logic like applying custom parameters and so on should be made @@ -61,52 +61,3 @@ def build_designspace(masters, master_dir, out_dir, instance_data): """ # TODO: (jany) check whether this function is still useful raise NotImplementedError - - -def _set_class_from_instance(ufo, data, key, codes): - class_name = getattr(data, key) - if class_name: - ufo.lib[GLYPHS_PREFIX + key + "Class"] = class_name - if class_name in codes: - class_code = codes[class_name] - ufo_key = "".join(['openTypeOS2', key[0].upper(), key[1:], 'Class']) - setattr(ufo.info, ufo_key, class_code) - - -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, "weight", 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, "width", 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. - """ - # FIXME: (jany) This is implemented because fontmake calls it. - # The instance_data will be an array of InstanceDescriptors - import defcon - - instance_ufos = [] - for path, data in instance_data: - ufo = defcon.Font(path) - set_weight_class(ufo, data) - set_width_class(ufo, data) - self = UFOBuilder(instance_data, defcon) - # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? - to_ufo_custom_params(self, ufo, data) - ufo.save() - instance_ufos.append(ufo) - return instance_ufos diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py index 7383eeba8..f1d2115b9 100644 --- a/tests/builder/interpolation_test.py +++ b/tests/builder/interpolation_test.py @@ -27,7 +27,7 @@ import defcon from fontTools.misc.py23 import open from glyphsLib.builder.constants import GLYPHS_PREFIX -from glyphsLib.interpolation import set_weight_class, set_width_class +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 @@ -122,6 +122,14 @@ def makeInstance(name, weight=None, width=None, is_bold=None, is_italic=None, 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 @@ -390,88 +398,92 @@ 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")) + 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 - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Regular") + # 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() - data = makeInstance( + doc, data = makeInstanceDescriptor( "Bold", weight=("Bold", None, 150) ) - set_weight_class(ufo, data) + set_weight_class(ufo, doc, data) self.assertEqual(ufo.info.openTypeOS2WeightClass, 700) - self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") + # self.assertEqual(ufo.lib[WEIGHT_CLASS_KEY], "Bold") def test_explicit_default_weight(self): ufo = defcon.Font() - data = makeInstance( + doc, data = makeInstanceDescriptor( "Regular", weight=("Regular", None, 100) ) - set_weight_class(ufo, data) + 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") + # 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")) + 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)") + # self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") def test_width_class(self): ufo = defcon.Font() - data = makeInstance( + doc, data = makeInstanceDescriptor( "Condensed", width=("Condensed", 3, 80) ) - set_width_class(ufo, data) + set_width_class(ufo, doc, data) self.assertEqual(ufo.info.openTypeOS2WidthClass, 3) - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") + # self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Condensed") def test_explicit_default_width(self): ufo = defcon.Font() - data = makeInstance( + doc, data = makeInstanceDescriptor( "Regular", width=("Medium (normal)", 5, 100) ) - set_width_class(ufo, data) + 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)") + # self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "Medium (normal)") def test_weight_and_width_class(self): ufo = defcon.Font() - data = makeInstance( + doc, data = makeInstanceDescriptor( "SemiCondensed ExtraBold", weight=("ExtraBold", None, 160), width=("SemiCondensed", 4, 90) ) - set_weight_class(ufo, data) - set_width_class(ufo, data) + 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.lib[WEIGHT_CLASS_KEY], "ExtraBold") self.assertEqual(ufo.info.openTypeOS2WidthClass, 4) - self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") + # self.assertEqual(ufo.lib[WIDTH_CLASS_KEY], "SemiCondensed") - def test_unknown_weight_class(self): + 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/ @@ -479,16 +491,37 @@ def test_unknown_weight_class(self): # 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( + doc, data = makeInstanceDescriptor( "DemiLight Italic", weight=("DemiLight", 350, 70) ) - set_weight_class(ufo, data) + 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 - self.assertTrue(ufo.info.openTypeOS2WeightClass is None) + # 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_designspace_roundtrip(tmpdir): diff --git a/tests/writer_test.py b/tests/writer_test.py index dc74e659b..aa41e157a 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -242,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() From e5730bc2696111b92f7efdbc149bcf2c7d32b4f7 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 19 Feb 2018 11:42:47 +0000 Subject: [PATCH 27/44] Bring bac `build_masters`, `apply_instance_data` etc --- Lib/glyphsLib/__init__.py | 8 +- Lib/glyphsLib/builder/builders.py | 3 +- Lib/glyphsLib/builder/font.py | 4 +- Lib/glyphsLib/builder/instances.py | 20 ++++- Lib/glyphsLib/builder/sources.py | 2 - Lib/glyphsLib/interpolation.py | 47 ++++++++--- tests/builder/interpolation_test.py | 120 ++++++---------------------- 7 files changed, 85 insertions(+), 119 deletions(-) diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index d0d4ef1bb..f9f1665e6 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -25,6 +25,8 @@ 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 @@ -90,8 +92,10 @@ def build_masters(filename, master_dir, designspace_instance_dir=None, 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 designspace in place of `instance_data`. - return ufos, designspace_path, designspace + # 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: return ufos diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index f8853baf6..c94813ed3 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -123,6 +123,7 @@ def masters(self): 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 @@ -255,7 +256,7 @@ def instance_data(self): if self._do_filter_instances_by_family: instances = list( filter_instances_by_family(instances, self.family_name)) - instance_data = {'data': instances} + instance_data = {'data': instances, 'designspace': self.designspace} first_ufo = next(iter(self.masters)) diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index f6da59d6d..80cdb5857 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -53,7 +53,7 @@ def to_ufo_font_attributes(self, family_name): glyph_order = list(glyph.name for glyph in font.glyphs) for index, master in enumerate(font.masters): - source = self.designspace.newSourceDescriptor() + source = self._designspace.newSourceDescriptor() ufo = self.ufo_module.Font() source.font = ufo @@ -87,7 +87,7 @@ def to_ufo_font_attributes(self, family_name): ufo.lib[MASTER_ORDER_LIB_KEY] = index # FIXME: (jany) in the future, yield this UFO (for memory, lazy iter) - self.designspace.addSource(source) + self._designspace.addSource(source) self._sources[master.id] = source diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index 057c00fd9..10fcfd101 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -26,6 +26,7 @@ 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' @@ -326,15 +327,17 @@ def set_width_class(ufo, designspace, instance): _set_class_from_instance(ufo, designspace, instance, WIDTH_AXIS_DEF) -def apply_instance_data(designspace): +# DEPRECATED: needs better API +def apply_instance_data(instance_data): """Open instances, apply data, and re-save. Args: - instance_data: DesignSpaceDocument object with some instances + 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: @@ -344,9 +347,18 @@ def apply_instance_data(designspace): set_width_class(ufo, designspace, instance) glyphs_instance = InstanceDescriptorAsGSInstance(instance) - builder = UFOBuilder(instance, defcon) # to_ufo_custom_params(self, ufo, data.parent) # FIXME: (jany) needed? - to_ufo_custom_params(self, ufo, instance) + 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/sources.py b/Lib/glyphsLib/builder/sources.py index c53654321..87e9e4ee8 100644 --- a/Lib/glyphsLib/builder/sources.py +++ b/Lib/glyphsLib/builder/sources.py @@ -45,8 +45,6 @@ def _to_designspace_source(self, master, is_regular): # UFO_SOURCE_NAME_KEY source.name = '%s %s' % (source.familyName, source.styleName) - # TODO: (jany) make sure to use forward slashes? Maybe it should be the - # responsibility of DesignspaceDocument if UFO_FILENAME_KEY in master.userData: source.filename = master.userData[UFO_FILENAME_KEY] else: diff --git a/Lib/glyphsLib/interpolation.py b/Lib/glyphsLib/interpolation.py index e605f3ef7..2f4149728 100644 --- a/Lib/glyphsLib/interpolation.py +++ b/Lib/glyphsLib/interpolation.py @@ -24,7 +24,7 @@ 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 +from glyphsLib.builder.instances import apply_instance_data, InstanceData from glyphsLib.util import build_ufo_path, write_ufo, clean_ufo @@ -35,23 +35,28 @@ logger = logging.getLogger(__name__) +# DEPRECATED def interpolate(ufos, master_dir, out_dir, instance_data, round_geometry=True): """Create MutatorMath designspace and generate instances. Returns instance UFOs. """ - # TODO: (jany) This should not be in glyphsLib, but rather an instance - # method of the designspace document, or another thing like - # ufoProcessor/mutatorMath.build() - # GlyphsLib should put all that is necessary to interpolate into the - # InstanceDescriptor (lib if needed) - # All the logic like applying custom parameters and so on should be made - # glyphs-agnostic (because most of it should be relevant for other build - # systems as well?) - # or the logic that is really specific to Glyphs could be implemented as - # in apply_instance_data: InstanceDescriptor -> UFO of the instance. - raise NotImplementedError + # Problem with this function: should take a designspace explicitly. + from mutatorMath.ufo import build + designspace_path, instance_files = build_designspace( + ufos, master_dir, out_dir, instance_data) + logger.info('Building instances') + for path, _ in instance_files: + clean_ufo(path) + build(designspace_path, outputUFOFormatVersion=3, + roundGeometry=round_geometry) + + instance_ufos = apply_instance_data(instance_files) + return instance_ufos + + +# DEPRECATED def build_designspace(masters, master_dir, out_dir, instance_data): """Just create MutatorMath designspace without generating instances. @@ -60,4 +65,20 @@ def build_designspace(masters, master_dir, out_dir, instance_data): Glyphs data for that instance. """ # TODO: (jany) check whether this function is still useful - raise NotImplementedError + # 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)) + + designspace_path = os.path.join(master_dir, designspace.filename) + designspace.write(designspace_path) + + return designspace_path, InstanceData(designspace) diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py index f1d2115b9..4df182bdb 100644 --- a/tests/builder/interpolation_test.py +++ b/tests/builder/interpolation_test.py @@ -29,7 +29,8 @@ 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 +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: @@ -524,100 +525,29 @@ def test_unknown_weight_class(self): self.assertTrue(ufo.info.openTypeOS2WeightClass == 400) -# def test_designspace_roundtrip(tmpdir): -# return -# doc = DesignSpaceDocument() -# -# weight = doc.newAxisDescriptor() -# weight.minimum = 1 -# weight.maximum = 1000 -# weight.default = 400 -# weight.name = "weight" -# weight.tag = "wght" -# weight.labelNames['fa-IR'] = "قطر" -# weight.labelNames['en'] = "Wéíght" -# weight.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] -# doc.addAxis(weight) -# -# # No width axis (to check that a default one is not created) -# -# # One custom axis -# craziness = doc.newAxisDescriptor() -# craziness.minimum = -0.5 -# craziness.maximum = 0.5 -# craziness.default = 0 -# craziness.name = 'craziness' -# craziness.tag = 'CRZY' -# craziness.labelNames['en'] = 'Craziness' -# craziness.map = [] -# doc.addAxis(craziness) -# -# # Sources -# -# light_narrow = doc.newSourceDescriptor() -# light_narrow.font = defcon.Font() -# light_narrow.filename = 'sources/Cool Font LtNw.ufo' -# light_narrow.name = 'light_narrow' -# light_narrow.copyLib = True -# light_narrow.copyInfo = True -# light_narrow.copyFeatures = True -# light_narrow.location = dict( -# weight=1, # FIXME: should it be 1 or 10? -# craziness=0) -# light_narrow.familyName = "Cool Font" -# light_narrow.styleName = "Narrow Light" -# light_narrow.mutedGlyphNames.append("A") -# light_narrow.mutedGlyphNames.append("Z") -# doc.addSource(light_narrow) -# -# bold_narrow = doc.newSourceDescriptor() -# bold_narrow.font = defcon.Font() -# bold_narrow.filename = 'sources/Cool Font BdNw.ufo' -# bold_narrow.name = 'bold_narrow' -# bold_narrow.location = dict( -# weight=990, # FIXME: Should it be 1000 or 990? -# craziness=0) -# bold_narrow.familyName = "Cool Font" -# bold_narrow.styleName = "Narrow Bold" -# doc.addSource(bold_narrow) -# -# light_narrow_crazy = doc.newSourceDescriptor() -# light_narrow_crazy.font = defcon.Font() -# light_narrow_crazy.filename = 'sources/Cool Font BdNw.ufo' -# light_narrow_crazy.name = 'light_narrow' -# light_narrow_crazy.location = dict( -# weight=1, # FIXME: should it be 1 or 10? -# craziness=0.5) -# light_narrow_crazy.familyName = "Cool Font" -# light_narrow_crazy.styleName = "Narrow Bold" -# doc.addSource(light_narrow_crazy) -# -# # A source with mostly blank attributes to check that it does not crash -# out_of_place = doc.newSourceDescriptor() -# out_of_place.font = defcon.Font() -# doc.addSource(out_of_place) -# -# # Instances -# -# # TODO -# -# font = to_glyphs(doc) -# -# # TODO: check how stuff is stored for Glyphs -# -# rtdoc = to_designspace(font) -# -# # Compare -# path = os.path.join(str(tmpdir), 'original.designspace') -# doc.write(path) -# with open(path) as fp: -# xml = fp.read() -# rtpath = os.path.join(str(tmpdir), 'rt.designspace') -# rtdoc.write(rtpath) -# with open(rtpath) as fp: -# rtxml = fp.read() -# -# assert xml == rtxml +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__": From f3eae29909f825f618b55f07ff0cc8b90ac7edcd Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 19 Feb 2018 12:18:27 +0000 Subject: [PATCH 28/44] Add a test that calls build_masters --- tests/main_test.py | 64 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/tests/main_test.py b/tests/main_test.py index dc1b2f795..2de4a07e5 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -20,23 +20,49 @@ 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( - list(map(str, expected.splitlines())), - list(map(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): + """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') + 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_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.' From 5c318ba61378ee955ceb97314d26f87e29b1b061 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 19 Feb 2018 14:20:11 +0000 Subject: [PATCH 29/44] Fix some regressions from version 2.2.1 --- Lib/glyphsLib/__init__.py | 5 ++++- Lib/glyphsLib/builder/custom_params.py | 8 ++++++++ Lib/glyphsLib/builder/instances.py | 3 +++ Lib/glyphsLib/builder/masters.py | 6 ------ Lib/glyphsLib/builder/names.py | 23 ++++++++++++++--------- tests/builder/custom_params_test.py | 7 +++++++ tests/main_test.py | 21 ++++++++++++++++++--- 7 files changed, 54 insertions(+), 19 deletions(-) diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py index f9f1665e6..5f2dfc102 100644 --- a/Lib/glyphsLib/__init__.py +++ b/Lib/glyphsLib/__init__.py @@ -78,9 +78,12 @@ def build_masters(filename, master_dir, designspace_instance_dir=None, """ 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=designspace_instance_dir) + instance_dir=instance_dir) ufos = [] for source in designspace.sources: ufos.append(source.font) diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index f8c828393..c01ccb057 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -320,11 +320,19 @@ def register(handler): 'macintoshFONDFamilyID', 'macintoshFONDName', + + 'trademark', + + 'styleMapFamilyName' ) for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: register(ParamHandler(name)) +# TODO: 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) diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index 10fcfd101..1bcb30b00 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -342,6 +342,9 @@ def apply_instance_data(instance_data): 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) diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index 3d4c323a2..2dd6a434a 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -23,7 +23,6 @@ MASTER_ID_LIB_KEY = GLYPHS_PREFIX + 'fontMasterID' UFO_FILENAME_KEY = GLYPHLIB_PREFIX + 'ufoFilename' UFO_YEAR_KEY = GLYPHLIB_PREFIX + 'ufoYear' -UFO_TRADEMARK_KEY = GLYPHLIB_PREFIX + 'ufoTrademark' UFO_NOTE_KEY = GLYPHLIB_PREFIX + 'ufoNote' @@ -47,9 +46,6 @@ def to_ufo_master_attributes(self, source, master): year = master.userData[UFO_YEAR_KEY] if year is not None: ufo.info.year = year - trademark = master.userData[UFO_TRADEMARK_KEY] - if trademark is not None: - ufo.info.trademark = trademark note = master.userData[UFO_NOTE_KEY] if note is not None: ufo.info.note = note @@ -120,8 +116,6 @@ def to_glyphs_master_attributes(self, source, master): if ufo.info.year is not None: master.userData[UFO_YEAR_KEY] = ufo.info.year - if ufo.info.trademark is not None: - master.userData[UFO_TRADEMARK_KEY] = ufo.info.trademark if ufo.info.note is not None: master.userData[UFO_NOTE_KEY] = ufo.info.note diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index f9ea06ef9..12fd2944e 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -30,17 +30,22 @@ def to_ufo_names(self, ufo, master, family_name): custom, is_italic ) - # FIXME: (jany) should be the responsibility of ufo2ft? - 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, diff --git a/tests/builder/custom_params_test.py b/tests/builder/custom_params_test.py index be51e8a04..0203a1eda 100644 --- a/tests/builder/custom_params_test.py +++ b/tests/builder/custom_params_test.py @@ -259,3 +259,10 @@ def test_empty_fstype(self): self.master.customParameters["fsType"] = [] self.set_custom_params() self.assertEqual(self.ufo.info.openTypeOS2Type, []) + + def test_version_string(self): + # TODO: 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/main_test.py b/tests/main_test.py index 2de4a07e5..4a22838df 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -40,9 +40,6 @@ def test_glyphs_main_masters(tmpdir): def test_glyphs_main_instances(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') @@ -54,6 +51,24 @@ def test_glyphs_main_instances(tmpdir): 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. From adf91556f20dd461321c6e456cd96a7a3c6d474e Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 19 Feb 2018 17:10:46 +0000 Subject: [PATCH 30/44] Add example code --- Lib/glyphsLib/builder/__init__.py | 3 +- Lib/glyphsLib/builder/builders.py | 6 +-- Lib/glyphsLib/builder/groups.py | 7 +-- README.rst | 76 ++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index fb0b10867..4ebd71296 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -94,8 +94,7 @@ def to_glyphs(ufos_or_designspace, so we should have to_glyphs(to_ufos(font)) == font and also to_glyphs(to_designspace(font)) == font """ - # FIXME: (jany) duck-type instead of isinstance - if isinstance(ufos_or_designspace, DesignSpaceDocument): + if hasattr(ufos_or_designspace, 'sources'): builder = GlyphsBuilder(designspace=ufos_or_designspace, glyphs_module=glyphs_module, minimize_ufo_diffs=minimize_ufo_diffs) diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index c94813ed3..232a2b532 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -346,14 +346,14 @@ def __init__(self, for source in designspace.sources: # FIXME: (jany) Do something better for the InMemory stuff # Is it an in-memory source descriptor? - if not hasattr(source, 'font'): + if not hasattr(source, 'font') or source.font is None: if source.path: # FIXME: (jany) consider not mucking with the caller's objects - source.font = designspace.fontClass(source.path) + source.font = defcon.Font(source.path) else: dirname = os.path.dirname(designspace.path) ufo_path = os.path.join(dirname, source.filename) - source.font = designspace.fontClass(ufo_path) + source.font = defcon.Font(ufo_path) elif ufos: self.designspace = self._fake_designspace(ufos) else: diff --git a/Lib/glyphsLib/builder/groups.py b/Lib/glyphsLib/builder/groups.py index 5a545c9a0..86d6e538a 100644 --- a/Lib/glyphsLib/builder/groups.py +++ b/Lib/glyphsLib/builder/groups.py @@ -174,13 +174,14 @@ def _warn(message, *args): 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 glyphs != reference_glyphs: + 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(glyphs)) - _warn(" current = %s", ' '.join(reference_glyphs)) + _warn(" reference = %s", ' '.join(sorted(glyphs))) + _warn(" current = %s", ' '.join(sorted(reference_glyphs))) def _ufo_logging_ref(ufo): diff --git a/README.rst b/README.rst index 048fc7a96..b2a7004bd 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 From fa4433b76ad9e8f73e8b1565e2001fb05a9d71d7 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 19 Feb 2018 17:33:39 +0000 Subject: [PATCH 31/44] Fix rst formatting in README --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index b2a7004bd..264cde366 100644 --- a/README.rst +++ b/README.rst @@ -62,7 +62,7 @@ Go back and forth between UFOs and Glyphs 1. Without a designspace file, using for example the `Inria fonts by Black[Foundry] `__: -..code: python +.. code:: python import glob from defcon import Font @@ -77,7 +77,7 @@ Go back and forth between UFOs and Glyphs 2. With a designspace, using `Spectral from Production Type `__: -..code: python +.. code:: python import glob from fontTools.designspaceLib import DesignSpaceDocument @@ -101,7 +101,7 @@ The same option exists for people who want to do Glyphs->UFOs->Glyphs: 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 +.. code:: python import glob import os From d1e5dfffb443a104c2b942f6b77c836c1a2a84e5 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 21 Feb 2018 10:57:53 +0000 Subject: [PATCH 32/44] Don't crash if the UFO background is before the foreground --- Lib/glyphsLib/builder/builders.py | 8 +++++++- tests/builder/to_glyphs_test.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 232a2b532..2545a8375 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -382,7 +382,7 @@ def font(self): self._font.masters.insert(len(self._font.masters), master) self._sources[master.id] = source - for layer in source.font.layers: + 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) @@ -488,3 +488,9 @@ def _fake_designspace(self, ufos): 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/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 963346b92..418441c0f 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -466,3 +466,15 @@ def test_open_contour(): 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]) From c63f8afd6e6a3f2b9b1a475e86babd831c93116a Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 21 Feb 2018 14:08:18 +0000 Subject: [PATCH 33/44] Fix crash when only a background --- Lib/glyphsLib/builder/annotations.py | 4 ++-- Lib/glyphsLib/builder/layers.py | 17 +++++++++++------ tests/builder/to_glyphs_test.py | 9 +++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Lib/glyphsLib/builder/annotations.py b/Lib/glyphsLib/builder/annotations.py index 19ddd6cd0..52abae0c2 100644 --- a/Lib/glyphsLib/builder/annotations.py +++ b/Lib/glyphsLib/builder/annotations.py @@ -51,8 +51,8 @@ def to_glyphs_annotations(self, ufo_glyph, layer): for attr in ['angle', 'position', 'text', 'type', 'width']: if attr in annot and annot[attr]: if attr == 'position': - position = Point() - position.x, position.y = annot[attr] + # annot['position'] can be either "{1, 2}" or (1, 2) + position = Point(annot['position']) annotation.position = position else: setattr(annotation, attr, annot[attr]) diff --git a/Lib/glyphsLib/builder/layers.py b/Lib/glyphsLib/builder/layers.py index a028b0645..3a8e75720 100644 --- a/Lib/glyphsLib/builder/layers.py +++ b/Lib/glyphsLib/builder/layers.py @@ -24,13 +24,9 @@ def to_glyphs_layer(self, ufo_layer, glyph, master): if ufo_layer.name == 'public.default': # TODO: (jany) constant - if master.id not in glyph.layers: - glyph.layers[master.id] = self.glyphs_module.GSLayer() - layer = glyph.layers[master.id] - layer.layerId = master.id - layer.name = master.name + layer = _get_or_make_foreground(self, glyph, master) elif ufo_layer.name == 'public.background': - master_layer = glyph.layers[master.id] + 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 @@ -65,6 +61,15 @@ def to_glyphs_layer(self, ufo_layer, glyph, master): 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 diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 418441c0f..9324900ff 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -478,3 +478,12 @@ def test_background_before_foreground(): # 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]) From 6f21462d40556b6b427d1007483027afe7f51269 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 22 Feb 2018 17:39:03 +0000 Subject: [PATCH 34/44] Don't add a GDEF to the features when round-tripping --- Lib/glyphsLib/builder/features.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index ecb322c30..9e521e37a 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -75,7 +75,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( From 93e8a62c604d3cfe4f1f3dc37abd99beb53d3e12 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 12 Feb 2018 12:53:18 +0000 Subject: [PATCH 35/44] Depend on fontTools >= 3.24.0 and its designspaceLib --- Lib/glyphsLib/builder/__init__.py | 3 +- Lib/glyphsLib/builder/builders.py | 18 +- Lib/glyphsLib/builder/features.py | 2 +- Lib/glyphsLib/designSpaceDocument.py | 2489 ------------------- requirements.txt | 2 +- setup.py | 2 +- tests/builder/interpolation_test.py | 2 +- tests/builder/lib_and_user_data_test.py | 7 +- tests/builder/to_glyphs_test.py | 3 +- tests/run_various_tests_on_various_files.py | 3 +- tests/test_helpers.py | 12 +- 11 files changed, 21 insertions(+), 2522 deletions(-) delete mode 100644 Lib/glyphsLib/designSpaceDocument.py diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index 4ebd71296..498614205 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -17,8 +17,7 @@ from glyphsLib import classes import defcon -# FIXME: (jany) import from fonttools -from glyphsLib.designSpaceDocument import DesignSpaceDocument +from fontTools.designspaceLib import DesignSpaceDocument from .builders import UFOBuilder, GlyphsBuilder diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 2545a8375..aff057111 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -22,8 +22,7 @@ import defcon -# FIXME: import fontTools.designSpaceDocument -from glyphsLib import designSpaceDocument +from fontTools import designspaceLib from glyphsLib import classes, glyphdata_generated from .constants import PUBLIC_PREFIX, GLYPHS_PREFIX, FONT_CUSTOM_PARAM_PREFIX @@ -51,7 +50,7 @@ class UFOBuilder(_LoggerMixin): def __init__(self, font, ufo_module=defcon, - designspace_module=designSpaceDocument, + designspace_module=designspaceLib, family_name=None, instance_dir=None, propagate_anchors=True, @@ -65,7 +64,7 @@ def __init__(self, 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. Should look like designSpaceDocument. + 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 @@ -94,9 +93,7 @@ def __init__(self, # the master UFOs, when the user requests them. # The axes, instances, rules... will only be built if the designspace # document itself is requested by the user. - self._designspace = self.designspace_module.DesignSpaceDocument( - writerClass=designSpaceDocument.InMemoryDocWriter, - fontClass=self.ufo_module.Font) + self._designspace = self.designspace_module.DesignSpaceDocument() self._designspace_is_complete = False # check that source was generated with at least stable version 2.3 @@ -344,11 +341,9 @@ def __init__(self, if ufos: raise NotImplementedError for source in designspace.sources: - # FIXME: (jany) Do something better for the InMemory stuff - # Is it an in-memory source descriptor? if not hasattr(source, 'font') or source.font is None: if source.path: - # FIXME: (jany) consider not mucking with the caller's objects + # FIXME: (jany) consider not changing the caller's objects source.font = defcon.Font(source.path) else: dirname = os.path.dirname(designspace.path) @@ -417,8 +412,7 @@ 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 = designSpaceDocument.DesignSpaceDocument( - writerClass=designSpaceDocument.InMemoryDocWriter) + designspace = designspaceLib.DesignSpaceDocument() ufo_to_location = defaultdict(dict) diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index 9e521e37a..af9477b57 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -209,7 +209,7 @@ def _build_end_locations(self): # 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(fake_location, "Sentinel")) + 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() diff --git a/Lib/glyphsLib/designSpaceDocument.py b/Lib/glyphsLib/designSpaceDocument.py deleted file mode 100644 index 505691970..000000000 --- a/Lib/glyphsLib/designSpaceDocument.py +++ /dev/null @@ -1,2489 +0,0 @@ -# -*- coding: utf-8 -*- - - -# FIXME: (jany) copy-pasted from https://github.com/LettError/designSpaceDocument -# TODO: move to fontTools https://github.com/LettError/designSpaceDocument/issues/28 -# https://github.com/fonttools/fonttools/issues/911 - - - - - - -from __future__ import print_function, division, absolute_import -import collections -import logging -import os -import posixpath -import plistlib -import xml.etree.ElementTree as ET -from mutatorMath.objects.location import biasFromLocations, Location - -""" - designSpaceDocument - - - read and write designspace files - - axes must be defined. - - warpmap is stored in its axis element -""" - -__all__ = [ - 'DesignSpaceDocumentError', 'DesignSpaceDocument', 'SourceDescriptor', - 'InstanceDescriptor', 'AxisDescriptor', 'RuleDescriptor', 'BaseDocReader', - 'BaseDocWriter' -] - - -def to_plist(value): - try: - # Python 2 - string = plistlib.writePlistToString(value) - except AttributeError: - # Python 3 - string = plistlib.dumps(value).decode() - return ET.fromstring(string).getchildren()[0] - - -def from_plist(element): - if element is None: - return {} - plist = ET.Element('plist') - plist.append(element) - string = ET.tostring(plist) - try: - # Python 2 - return plistlib.readPlistFromString(string) - except AttributeError: - # Python 3 - return plistlib.loads(string, fmt=plistlib.FMT_XML) - - -def posix(path): - """Normalize paths using forward slash to work also on Windows.""" - new_path = posixpath.join(*path.split(os.path.sep)) - if path.startswith('/'): - # The above transformation loses absolute paths - new_path = '/' + new_path - return new_path - - -class DesignSpaceDocumentError(Exception): - def __init__(self, msg, obj=None): - self.msg = msg - self.obj = obj - - def __str__(self): - return repr(self.msg) + repr(self.obj) - - -class NoFontError(DesignSpaceDocumentError): - """Raised when a SourceDescriptor cannot be linked to a source UFO.""" - - -def _indent(elem, whitespace=" ", level=0): - # taken from http://effbot.org/zone/element-lib.htm#prettyprint - i = "\n" + level * whitespace - if len(elem): - if not elem.text or not elem.text.strip(): - elem.text = i + whitespace - if not elem.tail or not elem.tail.strip(): - elem.tail = i - for elem in elem: - _indent(elem, whitespace, level+1) - if not elem.tail or not elem.tail.strip(): - elem.tail = i - else: - if level and (not elem.tail or not elem.tail.strip()): - elem.tail = i - - -class SimpleDescriptor(object): - """ Containers for a bunch of attributes""" - def compare(self, other): - # test if this object contains the same data as the other - for attr in self._attrs: - try: - assert(getattr(self, attr) == getattr(other, attr)) - except AssertionError: - print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr)) - - -class SourceDescriptor(SimpleDescriptor): - """Simple container for data related to the source""" - flavor = "source" - _attrs = ['filename', 'path', 'name', - 'location', 'copyLib', - 'copyGroups', 'copyFeatures', - 'muteKerning', 'muteInfo', - 'mutedGlyphNames', - 'familyName', 'styleName'] - - def __init__(self): - self.document = None # a reference to the parent document - self._filename = None # the original path as found in the document - self._path = None # the absolute path, calculated from filename - self.name = None - self.location = None - self.copyLib = False - self.copyInfo = False - self.copyGroups = False - self.copyFeatures = False - self.muteKerning = False - self.muteInfo = False - self.mutedGlyphNames = [] - self.familyName = None - self.styleName = None - - @property - def filename(self): - return self._filename - - @filename.setter - def filename(self, value): - if value is None: - self._filename = None - return - self._filename = posix(value) - - @property - def path(self): - return self._path - - @path.setter - def path(self, value): - if value is None: - self._path = None - return - self._path = posix(value) - - -class RuleDescriptor(SimpleDescriptor): - """ - - - - - - - - - - - Discussion: - use axis names rather than tags - then we can evaluate the rule without having to look up the axes. - remove the subs from the rule. - remove 'enabled' attr form rule - - - """ - _attrs = ['name', 'conditions', 'subs'] # what do we need here - def __init__(self): - self.name = None - self.conditions = [] # list of dict(tag='aaaa', minimum=0, maximum=1000) - self.subs = [] # list of substitutions stored as tuples of glyphnames ("a", "a.alt") - -def evaluateRule(rule, location): - """ Test if rule is True at location.maximum - If a condition has no minimum, check for < maximum. - If a condition has no maximum, check for > minimum. - """ - for cd in rule.conditions: - if not cd['name'] in location: - continue - if cd.get('minimum') is None: - if not location[cd['name']] <= cd['maximum']: - return False - elif cd.get('maximum') is None: - if not cd['minimum'] <= location[cd['name']]: - return False - else: - if not cd['minimum'] <= location[cd['name']] <= cd['maximum']: - return False - return True - -def processRules(rules, location, glyphNames): - """ Apply these rules at this location to these glyphnames.minimum - - rule order matters - """ - newNames = [] - for rule in rules: - if evaluateRule(rule, location): - for name in glyphNames: - swap = False - for a, b in rule.subs: - if name == a: - swap = True - break - if swap: - newNames.append(b) - else: - newNames.append(name) - glyphNames = newNames - newNames = [] - return glyphNames - - - - -class InstanceDescriptor(SimpleDescriptor): - """Simple container for data related to the instance""" - flavor = "instance" - _defaultLanguageCode = "en" - _attrs = [ 'path', - 'name', - 'location', - 'familyName', - 'styleName', - 'postScriptFontName', - 'styleMapFamilyName', - 'styleMapStyleName', - 'kerning', - 'info', - 'lib'] - - def __init__(self): - self._filename = None # the original path as found in the document - self._path = None # the absolute path, calculated from filename - self.name = None - self.location = None - self.familyName = None - self.styleName = None - self.postScriptFontName = None - self.styleMapFamilyName = None - self.styleMapStyleName = None - self.localisedStyleName = {} - self.localisedFamilyName = {} - self.localisedStyleMapStyleName = {} - self.localisedStyleMapFamilyName = {} - self.glyphs = {} - self.mutedGlyphNames = [] - self.kerning = True - self.info = True - self.lib = {} - - @property - def filename(self): - return self._filename - - @filename.setter - def filename(self, value): - if value is None: - self._filename = None - return - self._filename = posix(value) - - @property - def path(self): - return self._path - - @path.setter - def path(self, value): - if value is None: - self._path = None - return - self._path = posix(value) - - def setStyleName(self, styleName, languageCode="en"): - self.localisedStyleName[languageCode] = styleName - def getStyleName(self, languageCode="en"): - return self.localisedStyleName.get(languageCode) - - def setFamilyName(self, familyName, languageCode="en"): - self.localisedFamilyName[languageCode] = familyName - def getFamilyName(self, languageCode="en"): - return self.localisedFamilyName.get(languageCode) - - def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"): - self.localisedStyleMapStyleName[languageCode] = styleMapStyleName - def getStyleMapStyleName(self, languageCode="en"): - return self.localisedStyleMapStyleName.get(languageCode) - - def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"): - self.localisedStyleMapFamilyName[languageCode] = styleMapFamilyName - def getStyleMapFamilyName(self, languageCode="en"): - return self.localisedStyleMapFamilyName.get(languageCode) - -def tagForAxisName(name): - # try to find or make a tag name for this axis name - names = { - 'weight': ('wght', dict(en = 'Weight')), - 'width': ('wdth', dict(en = 'Width')), - 'optical': ('opsz', dict(en = 'Optical Size')), - 'slant': ('slnt', dict(en = 'Slant')), - 'italic': ('ital', dict(en = 'Italic')), - } - if name.lower() in names: - return names[name.lower()] - if len(name) < 4: - tag = name + "*"*(4-len(name)) - else: - tag = name[:4] - return tag, dict(en = name) - - -class AxisDescriptor(SimpleDescriptor): - """ Simple container for the axis data - Add more localisations? - """ - flavor = "axis" - _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map'] - - def __init__(self): - self.tag = None # opentype tag for this axis - self.name = None # name of the axis used in locations - self.labelNames = {} # names for UI purposes, if this is not a standard axis, - self.minimum = None - self.maximum = None - self.default = None - self.hidden = False - self.map = [] - - def serialize(self): - # output to a dict, used in testing - d = dict(tag = self.tag, - name = self.name, - labelNames = self.labelNames, - maximum = self.maximum, - minimum = self.minimum, - default = self.default, - hidden = self.hidden, - map = self.map, - ) - return d - - -class BaseDocWriter(object): - _whiteSpace = " " - ruleDescriptorClass = RuleDescriptor - axisDescriptorClass = AxisDescriptor - sourceDescriptorClass = SourceDescriptor - instanceDescriptorClass = InstanceDescriptor - - @classmethod - def getAxisDecriptor(cls, document): - return cls.axisDescriptorClass() - - @classmethod - def getSourceDescriptor(cls, document): - return cls.sourceDescriptorClass() - - @classmethod - def getInstanceDescriptor(cls, document): - return cls.instanceDescriptorClass() - - @classmethod - def getRuleDescriptor(cls, document): - return cls.ruleDescriptorClass() - - def __init__(self, documentPath, documentObject): - self.path = documentPath - self.documentObject = documentObject - self.toolVersion = 3 - self.root = ET.Element("designspace") - self.root.attrib['format'] = "%d" % self.toolVersion - #self.root.append(ET.Element("axes")) - #self.root.append(ET.Element("rules")) - #self.root.append(ET.Element("sources")) - #self.root.append(ET.Element("instances")) - self.axes = [] - self.rules = [] - - def newDefaultLocation(self): - loc = collections.OrderedDict() - for axisDescriptor in self.axes: - loc[axisDescriptor.name] = axisDescriptor.default - return loc - - def write(self, pretty=True): - if self.documentObject.axes: - self.root.append(ET.Element("axes")) - for axisObject in self.documentObject.axes: - self._addAxis(axisObject) - - if self.documentObject.rules: - self.root.append(ET.Element("rules")) - for ruleObject in self.documentObject.rules: - self._addRule(ruleObject) - - if self.documentObject.sources: - self.root.append(ET.Element("sources")) - for sourceObject in self.documentObject.sources: - self._addSource(sourceObject) - - if self.documentObject.instances: - self.root.append(ET.Element("instances")) - for instanceObject in self.documentObject.instances: - self._addInstance(instanceObject) - - if self.documentObject.lib: - self._addLib(self.documentObject.lib) - - if pretty: - _indent(self.root, whitespace=self._whiteSpace) - tree = ET.ElementTree(self.root) - tree.write(self.path, encoding="utf-8", method='xml', xml_declaration=True) - - def _makeLocationElement(self, locationObject, name=None): - """ Convert Location dict to a locationElement.""" - locElement = ET.Element("location") - if name is not None: - locElement.attrib['name'] = name - defaultLoc = self.newDefaultLocation() - # Without OrderedDict, output XML would be non-deterministic. - # https://github.com/LettError/designSpaceDocument/issues/10 - validatedLocation = collections.OrderedDict() - for axisName, axisValue in defaultLoc.items(): - # update the location dict with missing default axis values - validatedLocation[axisName] = locationObject.get(axisName, axisValue) - for dimensionName, dimensionValue in validatedLocation.items(): - dimElement = ET.Element('dimension') - dimElement.attrib['name'] = dimensionName - if type(dimensionValue) == tuple: - dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue[0]) - dimElement.attrib['yvalue'] = self.intOrFloat(dimensionValue[1]) - else: - dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue) - locElement.append(dimElement) - return locElement, validatedLocation - - def intOrFloat(self, num): - if int(num) == num: - return "%d" % num - return "%f" % num - - def _addRule(self, ruleObject): - # if none of the conditions have minimum or maximum values, do not add the rule. - self.rules.append(ruleObject) - ruleElement = ET.Element('rule') - ruleElement.attrib['name'] = ruleObject.name - for cond in ruleObject.conditions: - if cond.get('minimum') is None and cond.get('maximum') is None: - # neither is defined, don't add this condition - continue - conditionElement = ET.Element('condition') - conditionElement.attrib['name'] = cond.get('name') - if cond.get('minimum') is not None: - conditionElement.attrib['minimum'] = self.intOrFloat(cond.get('minimum')) - if cond.get('maximum') is not None: - conditionElement.attrib['maximum'] = self.intOrFloat(cond.get('maximum')) - ruleElement.append(conditionElement) - for sub in ruleObject.subs: - # skip empty subs - if sub[0] == '' and sub[1] == '': - continue - subElement = ET.Element('sub') - subElement.attrib['name'] = sub[0] - subElement.attrib['with'] = sub[1] - ruleElement.append(subElement) - self.root.findall('.rules')[0].append(ruleElement) - - def _addAxis(self, axisObject): - self.axes.append(axisObject) - axisElement = ET.Element('axis') - axisElement.attrib['tag'] = axisObject.tag - axisElement.attrib['name'] = axisObject.name - axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum) - axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum) - axisElement.attrib['default'] = self.intOrFloat(axisObject.default) - if axisObject.hidden: - axisElement.attrib['hidden'] = "1" - for languageCode, labelName in axisObject.labelNames.items(): - languageElement = ET.Element('labelname') - languageElement.attrib[u'xml:lang'] = languageCode - languageElement.text = labelName - axisElement.append(languageElement) - if axisObject.map: - for inputValue, outputValue in axisObject.map: - mapElement = ET.Element('map') - mapElement.attrib['input'] = self.intOrFloat(inputValue) - mapElement.attrib['output'] = self.intOrFloat(outputValue) - axisElement.append(mapElement) - self.root.findall('.axes')[0].append(axisElement) - - def _addInstance(self, instanceObject): - instanceElement = ET.Element('instance') - if instanceObject.name is not None: - instanceElement.attrib['name'] = instanceObject.name - if instanceObject.familyName is not None: - instanceElement.attrib['familyname'] = instanceObject.familyName - if instanceObject.styleName is not None: - instanceElement.attrib['stylename'] = instanceObject.styleName - # add localisations - if instanceObject.localisedStyleName: - languageCodes = instanceObject.localisedStyleName.keys() - languageCodes.sort() - for code in languageCodes: - if code == "en": continue # already stored in the element attribute - localisedStyleNameElement = ET.Element('stylename') - localisedStyleNameElement.attrib["xml:lang"] = code - localisedStyleNameElement.text = instanceObject.getStyleName(code) - instanceElement.append(localisedStyleNameElement) - if instanceObject.localisedFamilyName: - languageCodes = instanceObject.localisedFamilyName.keys() - languageCodes.sort() - for code in languageCodes: - if code == "en": continue # already stored in the element attribute - localisedFamilyNameElement = ET.Element('familyname') - localisedFamilyNameElement.attrib["xml:lang"] = code - localisedFamilyNameElement.text = instanceObject.getFamilyName(code) - instanceElement.append(localisedFamilyNameElement) - if instanceObject.localisedStyleMapStyleName: - languageCodes = instanceObject.localisedStyleMapStyleName.keys() - languageCodes.sort() - for code in languageCodes: - if code == "en": continue - localisedStyleMapStyleNameElement = ET.Element('stylemapstylename') - localisedStyleMapStyleNameElement.attrib["xml:lang"] = code - localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code) - instanceElement.append(localisedStyleMapStyleNameElement) - if instanceObject.localisedStyleMapFamilyName: - languageCodes = instanceObject.localisedStyleMapFamilyName.keys() - languageCodes.sort() - for code in languageCodes: - if code == "en": continue - localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname') - localisedStyleMapFamilyNameElement.attrib["xml:lang"] = code - localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code) - instanceElement.append(localisedStyleMapFamilyNameElement) - - if instanceObject.location is not None: - locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) - instanceElement.append(locationElement) - if instanceObject.filename is not None: - instanceElement.attrib['filename'] = instanceObject.filename - if instanceObject.postScriptFontName is not None: - instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName - if instanceObject.styleMapFamilyName is not None: - instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName - if instanceObject.styleMapStyleName is not None: - instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName - if instanceObject.glyphs: - if instanceElement.findall('.glyphs') == []: - glyphsElement = ET.Element('glyphs') - instanceElement.append(glyphsElement) - glyphsElement = instanceElement.findall('.glyphs')[0] - for glyphName, data in instanceObject.glyphs.items(): - glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) - glyphsElement.append(glyphElement) - if instanceObject.kerning: - kerningElement = ET.Element('kerning') - instanceElement.append(kerningElement) - if instanceObject.info: - infoElement = ET.Element('info') - instanceElement.append(infoElement) - if instanceObject.lib: - libElement = ET.Element('lib') - # TODO: (jany) PLIST I guess? - libElement.append(to_plist(instanceObject.lib)) - instanceElement.append(libElement) - self.root.findall('.instances')[0].append(instanceElement) - - def _addSource(self, sourceObject): - sourceElement = ET.Element("source") - if sourceObject.filename is not None: - sourceElement.attrib['filename'] = sourceObject.filename - if sourceObject.name is not None: - if sourceObject.name.find("temp_master")!=0: - # do not save temporary source names - sourceElement.attrib['name'] = sourceObject.name - if sourceObject.familyName is not None: - sourceElement.attrib['familyname'] = sourceObject.familyName - if sourceObject.styleName is not None: - sourceElement.attrib['stylename'] = sourceObject.styleName - if sourceObject.copyLib: - libElement = ET.Element('lib') - libElement.attrib['copy'] = "1" - sourceElement.append(libElement) - if sourceObject.copyGroups: - groupsElement = ET.Element('groups') - groupsElement.attrib['copy'] = "1" - sourceElement.append(groupsElement) - if sourceObject.copyFeatures: - featuresElement = ET.Element('features') - featuresElement.attrib['copy'] = "1" - sourceElement.append(featuresElement) - if sourceObject.copyInfo or sourceObject.muteInfo: - infoElement = ET.Element('info') - if sourceObject.copyInfo: - infoElement.attrib['copy'] = "1" - if sourceObject.muteInfo: - infoElement.attrib['mute'] = "1" - sourceElement.append(infoElement) - if sourceObject.muteKerning: - kerningElement = ET.Element("kerning") - kerningElement.attrib["mute"] = '1' - sourceElement.append(kerningElement) - if sourceObject.mutedGlyphNames: - for name in sourceObject.mutedGlyphNames: - glyphElement = ET.Element("glyph") - glyphElement.attrib["name"] = name - glyphElement.attrib["mute"] = '1' - sourceElement.append(glyphElement) - locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location) - sourceElement.append(locationElement) - self.root.findall('.sources')[0].append(sourceElement) - - def _addLib(self, dict): - libElement = ET.Element('lib') - # TODO: (jany) PLIST I guess? - libElement.append(to_plist(dict)) - self.root.append(libElement) - - def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): - glyphElement = ET.Element('glyph') - if data.get('mute'): - glyphElement.attrib['mute'] = "1" - if data.get('unicodes') is not None: - glyphElement.attrib['unicode'] = " ".join([hex(u) for u in data.get('unicodes')]) - if data.get('instanceLocation') is not None: - locationElement, data['instanceLocation'] = self._makeLocationElement(data.get('instanceLocation')) - glyphElement.append(locationElement) - if glyphName is not None: - glyphElement.attrib['name'] = glyphName - if data.get('note') is not None: - noteElement = ET.Element('note') - noteElement.text = data.get('note') - glyphElement.append(noteElement) - if data.get('masters') is not None: - mastersElement = ET.Element("masters") - for m in data.get('masters'): - masterElement = ET.Element("master") - if m.get('glyphName') is not None: - masterElement.attrib['glyphname'] = m.get('glyphName') - if m.get('font') is not None: - masterElement.attrib['source'] = m.get('font') - if m.get('location') is not None: - locationElement, m['location'] = self._makeLocationElement(m.get('location')) - masterElement.append(locationElement) - mastersElement.append(masterElement) - glyphElement.append(mastersElement) - return glyphElement - - -class BaseDocReader(object): - def __init__(self, documentPath, documentObject): - self.path = documentPath - self.documentObject = documentObject - self.documentObject.formatVersion = 0 - tree = ET.parse(self.path) - self.root = tree.getroot() - self.documentObject.formatVersion = int(self.root.attrib.get("format", 0)) - self.axes = [] - self.rules = [] - self.sources = [] - self.instances = [] - self.axisDefaults = {} - self._strictAxisNames = True - - def read(self): - self.readAxes() - self.readRules() - self.readSources() - self.readInstances() - self.readLib() - - def getSourcePaths(self, makeGlyphs=True, makeKerning=True, makeInfo=True): - paths = [] - for name in self.documentObject.sources.keys(): - paths.append(self.documentObject.sources[name][0].path) - return paths - - def newDefaultLocation(self): - loc = {} - for axisDescriptor in self.axes: - loc[axisDescriptor.name] = axisDescriptor.default - return loc - - def readRules(self): - # read the rules - rules = [] - for ruleElement in self.root.findall(".rules/rule"): - ruleObject = self.documentObject.newRuleDescriptor() - ruleObject.name = ruleElement.attrib.get("name") - for conditionElement in ruleElement.findall('.condition'): - cd = {} - cdMin = conditionElement.attrib.get("minimum") - if cdMin is not None: - cd['minimum'] = float(cdMin) - else: - # will allow these to be None, assume axis.minimum - cd['minimum'] = None - cdMax = conditionElement.attrib.get("maximum") - if cdMax is not None: - cd['maximum'] = float(cdMax) - else: - # will allow these to be None, assume axis.maximum - cd['maximum'] = None - cd['name'] = conditionElement.attrib.get("name") - ruleObject.conditions.append(cd) - for subElement in ruleElement.findall('.sub'): - a = subElement.attrib['name'] - b = subElement.attrib['with'] - ruleObject.subs.append((a,b)) - rules.append(ruleObject) - self.documentObject.rules = rules - - def readAxes(self): - # read the axes elements, including the warp map. - axes = [] - if len(self.root.findall(".axes/axis"))==0: - self.guessAxes() - self._strictAxisNames = False - return - for axisElement in self.root.findall(".axes/axis"): - axisObject = self.documentObject.newAxisDescriptor() - axisObject.name = axisElement.attrib.get("name") - axisObject.minimum = float(axisElement.attrib.get("minimum")) - axisObject.maximum = float(axisElement.attrib.get("maximum")) - if axisElement.attrib.get('hidden', False): - axisObject.hidden = True - # we need to check if there is an attribute named "initial" - if axisElement.attrib.get("default") is None: - if axisElement.attrib.get("initial") is not None: - # stop doing this, - axisObject.default = float(axisElement.attrib.get("initial")) - else: - axisObject.default = axisObject.minimum - else: - axisObject.default = float(axisElement.attrib.get("default")) - axisObject.tag = axisElement.attrib.get("tag") - for mapElement in axisElement.findall('map'): - a = float(mapElement.attrib['input']) - b = float(mapElement.attrib['output']) - axisObject.map.append((a,b)) - for labelNameElement in axisElement.findall('labelname'): - # Note: elementtree reads the xml:lang attribute name as - # '{http://www.w3.org/XML/1998/namespace}lang' - for key, lang in labelNameElement.items(): - labelName = labelNameElement.text - axisObject.labelNames[lang] = labelName - self.documentObject.axes.append(axisObject) - self.axisDefaults[axisObject.name] = axisObject.default - - def _locationFromElement(self, locationElement): - # mostly duplicated from readLocationElement, Needs Resolve. - loc = {} - for dimensionElement in locationElement.findall(".dimension"): - dimName = dimensionElement.attrib.get("name") - xValue = yValue = None - try: - xValue = dimensionElement.attrib.get('xvalue') - xValue = float(xValue) - except ValueError: - self.logger.info("KeyError in readLocation xValue %3.3f", xValue) - try: - yValue = dimensionElement.attrib.get('yvalue') - if yValue is not None: - yValue = float(yValue) - except ValueError: - pass - if yValue is not None: - loc[dimName] = (xValue, yValue) - else: - loc[dimName] = xValue - return loc - - def guessAxes(self): - # Called when we have no axes element in the file. - # Look at all locations and collect the axis names and values - # assumptions: - # look for the default value on an axis from a master location - allLocations = [] - minima = {} - maxima = {} - for locationElement in self.root.findall(".sources/source/location"): - allLocations.append(self._locationFromElement(locationElement)) - for locationElement in self.root.findall(".instances/instance/location"): - allLocations.append(self._locationFromElement(locationElement)) - for loc in allLocations: - for dimName, value in loc.items(): - if not isinstance(value, tuple): - value = [value] - for v in value: - if dimName not in minima: - minima[dimName] = v - continue - if minima[dimName] > v: - minima[dimName] = v - if dimName not in maxima: - maxima[dimName] = v - continue - if maxima[dimName] < v: - maxima[dimName] = v - newAxes = [] - for axisName in maxima.keys(): - a = self.documentObject.newAxisDescriptor() - a.default = a.minimum = minima[axisName] - a.maximum = maxima[axisName] - a.name = axisName - a.tag, a.labelNames = tagForAxisName(axisName) - self.documentObject.axes.append(a) - - def readSources(self): - for sourceCount, sourceElement in enumerate(self.root.findall(".sources/source")): - filename = sourceElement.attrib.get('filename') - if filename is not None and self.path is not None: - sourcePath = os.path.abspath(os.path.join(os.path.dirname(self.path), filename)) - else: - sourcePath = None - sourceName = sourceElement.attrib.get('name') - if sourceName is None: - # add a temporary source name - sourceName = "temp_master.%d"%(sourceCount) - sourceObject = self.documentObject.newSourceDescriptor() - sourceObject.path = sourcePath # absolute path to the ufo source - sourceObject.filename = filename # path as it is stored in the document - sourceObject.name = sourceName - familyName = sourceElement.attrib.get("familyname") - if familyName is not None: - sourceObject.familyName = familyName - styleName = sourceElement.attrib.get("stylename") - if styleName is not None: - sourceObject.styleName = styleName - sourceObject.location = self.locationFromElement(sourceElement) - for libElement in sourceElement.findall('.lib'): - if libElement.attrib.get('copy') == '1': - sourceObject.copyLib = True - for groupsElement in sourceElement.findall('.groups'): - if groupsElement.attrib.get('copy') == '1': - sourceObject.copyGroups = True - for infoElement in sourceElement.findall(".info"): - if infoElement.attrib.get('copy') == '1': - sourceObject.copyInfo = True - if infoElement.attrib.get('mute') == '1': - sourceObject.muteInfo = True - for featuresElement in sourceElement.findall(".features"): - if featuresElement.attrib.get('copy') == '1': - sourceObject.copyFeatures = True - for glyphElement in sourceElement.findall(".glyph"): - glyphName = glyphElement.attrib.get('name') - if glyphName is None: - continue - if glyphElement.attrib.get('mute') == '1': - sourceObject.mutedGlyphNames.append(glyphName) - for kerningElement in sourceElement.findall(".kerning"): - if kerningElement.attrib.get('mute') == '1': - sourceObject.muteKerning = True - self.documentObject.addSource(sourceObject) - - def locationFromElement(self, element): - elementLocation = None - for locationElement in element.findall('.location'): - elementLocation = self.readLocationElement(locationElement) - break - return elementLocation - - def readLocationElement(self, locationElement): - """ Format 0 location reader """ - loc = {} - for dimensionElement in locationElement.findall(".dimension"): - dimName = dimensionElement.attrib.get("name") - if self._strictAxisNames and dimName not in self.axisDefaults: - # In case the document contains axis definitions, - # then we should only read the axes we know about. - # However, if the document does not contain axes, - # then we need to create them after reading. - continue - xValue = yValue = None - try: - xValue = dimensionElement.attrib.get('xvalue') - xValue = float(xValue) - except ValueError: - self.logger.info("KeyError in readLocation xValue %3.3f", xValue) - try: - yValue = dimensionElement.attrib.get('yvalue') - if yValue is not None: - yValue = float(yValue) - except ValueError: - pass - if yValue is not None: - loc[dimName] = (xValue, yValue) - else: - loc[dimName] = xValue - return loc - - def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): - instanceElements = self.root.findall('.instances/instance') - for instanceElement in self.root.findall('.instances/instance'): - self._readSingleInstanceElement(instanceElement, makeGlyphs=makeGlyphs, makeKerning=makeKerning, makeInfo=makeInfo) - - def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True): - filename = instanceElement.attrib.get('filename') - if filename is not None: - instancePath = os.path.join(os.path.dirname(self.documentObject.path), filename) - filenameTokenForResults = os.path.basename(filename) - else: - instancePath = None - instanceObject = self.documentObject.newInstanceDescriptor() - instanceObject.path = instancePath # absolute path to the instance - instanceObject.filename = filename # path as it is stored in the document - name = instanceElement.attrib.get("name") - if name is not None: - instanceObject.name = name - familyname = instanceElement.attrib.get('familyname') - if familyname is not None: - instanceObject.familyName = familyname - stylename = instanceElement.attrib.get('stylename') - if stylename is not None: - instanceObject.styleName = stylename - postScriptFontName = instanceElement.attrib.get('postscriptfontname') - if postScriptFontName is not None: - instanceObject.postScriptFontName = postScriptFontName - styleMapFamilyName = instanceElement.attrib.get('stylemapfamilyname') - if styleMapFamilyName is not None: - instanceObject.styleMapFamilyName = styleMapFamilyName - styleMapStyleName = instanceElement.attrib.get('stylemapstylename') - if styleMapStyleName is not None: - instanceObject.styleMapStyleName = styleMapStyleName - # read localised names - for styleNameElement in instanceElement.findall('stylename'): - for key, lang in styleNameElement.items(): - styleName = styleNameElement.text - instanceObject.setStyleName(styleName, lang) - for familyNameElement in instanceElement.findall('familyname'): - for key, lang in familyNameElement.items(): - familyName = familyNameElement.text - instanceObject.setFamilyName(familyName, lang) - for styleMapStyleNameElement in instanceElement.findall('stylemapstylename'): - for key, lang in styleMapStyleNameElement.items(): - styleMapStyleName = styleMapStyleNameElement.text - instanceObject.setStyleMapStyleName(styleMapStyleName, lang) - for styleMapFamilyNameElement in instanceElement.findall('stylemapfamilyname'): - for key, lang in styleMapFamilyNameElement.items(): - styleMapFamilyName = styleMapFamilyNameElement.text - instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang) - instanceLocation = self.locationFromElement(instanceElement) - if instanceLocation is not None: - instanceObject.location = instanceLocation - for glyphElement in instanceElement.findall('.glyphs/glyph'): - self.readGlyphElement(glyphElement, instanceObject) - for infoElement in instanceElement.findall("info"): - self.readInfoElement(infoElement, instanceObject) - for libElement in instanceElement.findall('lib'): - self.readLibElement(libElement, instanceObject) - self.documentObject.instances.append(instanceObject) - - def readLibElement(self, libElement, instanceObject): - """ TODO: (jany) doc - """ - instanceObject.lib = from_plist(libElement.getchildren()[0]) - - def readInfoElement(self, infoElement, instanceObject): - """ Read the info element. - - :: - - - - Let's drop support for a different location for the info. Never needed it. - - """ - infoLocation = self.locationFromElement(infoElement) - instanceObject.info = True - - def readKerningElement(self, kerningElement, instanceObject): - """ Read the kerning element. - - :: - - Make kerning at the location and with the masters specified at the instance level. - - - """ - kerningLocation = self.locationFromElement(kerningElement) - instanceObject.addKerning(kerningLocation) - - def readGlyphElement(self, glyphElement, instanceObject): - """ - Read the glyph element. - - :: - - - - - - - - - - - This is an instance from an anisotropic interpolation. - - - - """ - glyphData = {} - glyphName = glyphElement.attrib.get('name') - if glyphName is None: - raise DesignSpaceDocumentError("Glyph object without name attribute.") - mute = glyphElement.attrib.get("mute") - if mute == "1": - glyphData['mute'] = True - # unicode - unicodes = glyphElement.attrib.get('unicode') - if unicodes is not None: - try: - unicodes = [int(u, 16) for u in unicodes.split(" ")] - glyphData['unicodes'] = unicodes - except ValueError: - raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes) - - note = None - for noteElement in glyphElement.findall('.note'): - glyphData['note'] = noteElement.text - break - instanceLocation = self.locationFromElement(glyphElement) - if instanceLocation is not None: - glyphData['instanceLocation'] = instanceLocation - glyphSources = None - for masterElement in glyphElement.findall('.masters/master'): - fontSourceName = masterElement.attrib.get('source') - sourceLocation = self.locationFromElement(masterElement) - masterGlyphName = masterElement.attrib.get('glyphname') - if masterGlyphName is None: - # if we don't read a glyphname, use the one we have - masterGlyphName = glyphName - d = dict(font=fontSourceName, - location=sourceLocation, - glyphName=masterGlyphName) - if glyphSources is None: - glyphSources = [] - glyphSources.append(d) - if glyphSources is not None: - glyphData['masters'] = glyphSources - instanceObject.glyphs[glyphName] = glyphData - - def readLib(self): - """ TODO: (jany) doc - """ - for libElement in self.root.findall(".lib"): - self.documentObject.lib = from_plist(libElement.getchildren()[0]) - - -class DesignSpaceDocument(object): - """ Read, write data from the designspace file""" - def __init__(self, readerClass=None, writerClass=None, fontClass=None): - self.logger = logging.getLogger("DesignSpaceDocumentLog") - self.path = None - self.filename = None # A preferred filename for the document - self.formatVersion = None - self.sources = [] - self.instances = [] - self.axes = [] - self.rules = [] - self.default = None # name of the default master - self.defaultLoc = None - self.lib = {} - # - if readerClass is not None: - self.readerClass = readerClass - else: - self.readerClass = BaseDocReader - if writerClass is not None: - self.writerClass = writerClass - else: - self.writerClass = BaseDocWriter - if fontClass is not None: - self.fontClass = fontClass - else: - from defcon.objects.font import Font - self.fontClass = Font - - def read(self, path): - self.path = path - reader = self.readerClass(path, self) - reader.read() - - def write(self, path): - self.path = path - self.updatePaths() - writer = self.writerClass(path, self) - writer.write() - - def _posixRelativePath(self, otherPath): - # FIXME: (jany) not needed anymore thanks to the descriptor accessors? - relative = os.path.relpath(otherPath, os.path.dirname(self.path)) - return posixpath.join(*relative.split(os.path.sep)) - - def updatePaths(self): - """ - Right before we save we need to identify and respond to the following situations: - In each descriptor, we have to do the right thing for the filename attribute. - - case 1. - descriptor.filename == None - descriptor.path == None - - -- action: - write as is, descriptors will not have a filename attr. - useless, but no reason to interfere. - - - case 2. - descriptor.filename == "../something" - descriptor.path == None - - -- action: - write as is. The filename attr should not be touched. - - - case 3. - descriptor.filename == None - descriptor.path == "~/absolute/path/there" - - -- action: - calculate the relative path for filename. - We're not overwriting some other value for filename, it should be fine - - - case 4. - descriptor.filename == '../somewhere' - descriptor.path == "~/absolute/path/there" - - -- action: - there is a conflict between the given filename, and the path. - So we know where the file is relative to the document. - Can't guess why they're different, we just choose for path to be correct and update filename. - - - """ - for descriptor in list(self.sources) + self.instances: - # check what the relative path really should be? - expectedFilename = None - if descriptor.path is not None and self.path is not None: - expectedFilename = self._posixRelativePath(descriptor.path) - - # 3 - if descriptor.filename is None and descriptor.path is not None and self.path is not None: - descriptor.filename = self._posixRelativePath(descriptor.path) - continue - - # 4 - if descriptor.filename is not None and descriptor.path is not None and self.path is not None: - if descriptor.filename is not expectedFilename: - descriptor.filename = expectedFilename - - @property - def sources(self): - # Return an immutable list to force users to call `addSource` - # or the setter. This is because I want source descriptors to keep a - # reference to their parent for their `font` property. - # Maybe this is all too much and another design is needed - # (where source descriptors don't instanciate fonts) - return tuple(self._sources) - - @sources.setter - def sources(self, sources): - self._sources = list(sources) - for source in self._sources: - source.document = self - - def addSource(self, sourceDescriptor): - sourceDescriptor.document = self - self._sources.append(sourceDescriptor) - - def addInstance(self, instanceDescriptor): - self.instances.append(instanceDescriptor) - - def addAxis(self, axisDescriptor): - self.axes.append(axisDescriptor) - - def addRule(self, ruleDescriptor): - self.rules.append(ruleDescriptor) - - def newDefaultLocation(self): - loc = {} - for axisDescriptor in self.axes: - loc[axisDescriptor.name] = axisDescriptor.default - return loc - - def updateFilenameFromPath(self, masters=True, instances=True, force=False): - # set a descriptor filename attr from the path and this document path - # if the filename attribute is not None: skip it. - if masters: - for descriptor in self.sources: - if descriptor.filename is not None and not force: - continue - if self.path is not None: - descriptor.filename = self._posixRelativePath(descriptor.path) - if instances: - for descriptor in self.instances: - if descriptor.filename is not None and not force: - continue - if self.path is not None: - descriptor.filename = self._posixRelativePath(descriptor.path) - - def getFonts(self): - # convenience method that delivers the masters and their locations - # so someone can build a thing for a thing. - fonts = [] - for sourceDescriptor in self.sources: - if sourceDescriptor.font is not None: - fonts.append(sourceDescriptor.font) - elif sourceDescriptor.path is not None: - if os.path.exists(sourceDescriptor.path): - f = self.fontClass(sourceDescriptor.path) - fonts.append((f, sourceDescriptor.location)) - return fonts - - def newAxisDescriptor(self): - # Ask the writer class to make us a new axisDescriptor - return self.writerClass.getAxisDecriptor(self) - - def newSourceDescriptor(self): - # Ask the writer class to make us a new sourceDescriptor - return self.writerClass.getSourceDescriptor(self) - - def newInstanceDescriptor(self): - # Ask the writer class to make us a new instanceDescriptor - return self.writerClass.getInstanceDescriptor(self) - - def newRuleDescriptor(self): - # Ask the writer class to make us a new instanceDescriptor - return self.writerClass.getRuleDescriptor(self) - - def getAxisOrder(self): - names = [] - for axisDescriptor in self.axes: - names.append(axisDescriptor.name) - return names - - def getAxis(self, name): - for axisDescriptor in self.axes: - if axisDescriptor.name == name: - return axisDescriptor - return None - - def check(self): - """ - After reading we need to make sure we have a valid designspace. - This means making repairs if things are missing - - check if we have axes and deduce them from the masters if they're missing - - that can include axes referenced in masters, instances, glyphs. - - if no default is assigned, use mutatormath to find out. - - record the default in the designspace - - report all the changes in a log - - save a "repaired" version of the doc - """ - self.checkAxes() - self.checkDefault() - - def checkDefault(self): - """ Check the sources for a copyInfo flag.""" - flaggedDefaultCandidate = None - for sourceDescriptor in self.sources: - names = set() - if sourceDescriptor.copyInfo: - # we choose you! - flaggedDefaultCandidate = sourceDescriptor - masterLocations = [src.location for src in self.sources] - mutatorBias = biasFromLocations(masterLocations, preferOrigin=False) - c = [src for src in self.sources if src.location==mutatorBias] - if c: - mutatorDefaultCandidate = c[0] - else: - mutatorDefaultCandidate = None - # what are we going to do? - if flaggedDefaultCandidate is not None: - if mutatorDefaultCandidate is not None: - if mutatorDefaultCandidate.name != flaggedDefaultCandidate.name: - # warn if we have a conflict - self.logger.info("Note: conflicting default masters:\n\tUsing %s as default\n\tMutator found %s"%(flaggedDefaultCandidate.name, mutatorDefaultCandidate.name)) - self.default = flaggedDefaultCandidate - self.defaultLoc = self.default.location - else: - # we have no flagged default candidate - # let's use the one from mutator - if flaggedDefaultCandidate is None and mutatorDefaultCandidate is not None: - # we didn't have a flag, use the one selected by mutator - self.default = mutatorDefaultCandidate - self.defaultLoc = self.default.location - self.default.copyInfo = True - # now that we have a default, let's check if the axes are ok - for axisObj in self.axes: - if axisObj.name not in self.default.location: - # extend the location of the neutral master with missing default value for this axis - self.default.location[axisObj.name] = axisObj.default - else: - if axisObj.default == self.default.location.get(axisObj.name): - continue - # proposed remedy: change default value in the axisdescriptor to the value of the neutral - neutralAxisValue = self.default.location.get(axisObj.name) - # make sure this value is between the min and max - if axisObj.minimum <= neutralAxisValue <= axisObj.maximum: - # yes we can fix this - axisObj.default = neutralAxisValue - self.logger.info("Note: updating the default value of axis %s to neutral master at %3.3f"%(axisObj.name, neutralAxisValue)) - # always fit the axis dimensions to the location of the designated neutral - elif neutralAxisValue < axisObj.minimum: - axisObj.default = neutralAxisValue - axisObj.minimum = neutralAxisValue - elif neutralAxisValue > axisObj.maximum: - axisObj.maximum = neutralAxisValue - axisObj.default = neutralAxisValue - else: - # now we're in trouble, can't solve this, alert. - self.logger.info("Warning: mismatched default value for axis %s and neutral master. Master value outside of axis bounds"%(axisObj.name)) - - - def _prepAxesForBender(self): - """ - Make the axis data we have available in - """ - benderAxes = {} - for axisDescriptor in self.axes: - d = { - 'name': axisDescriptor.name, - 'tag': axisDescriptor.tag, - 'minimum': axisDescriptor.minimum, - 'maximum': axisDescriptor.maximum, - 'default': axisDescriptor.default, - 'map': axisDescriptor.map, - } - benderAxes[axisDescriptor.name] = d - return benderAxes - - def checkAxes(self, overwrite=False): - """ - If we don't have axes in the document, make some, report - Should we include the instance locations when determining the axis extrema? - """ - axisValues = {} - # find all the axes - locations = [] - for sourceDescriptor in self.sources: - locations.append(sourceDescriptor.location) - for instanceDescriptor in self.instances: - locations.append(instanceDescriptor.location) - for name, glyphData in instanceDescriptor.glyphs.items(): - loc = glyphData.get("instanceLocation") - if loc is not None: - locations.append(loc) - for m in glyphData.get('masters', []): - locations.append(m['location']) - for loc in locations: - for name, value in loc.items(): - if not name in axisValues: - axisValues[name] = [] - if type(value)==tuple: - for v in value: - axisValues[name].append(v) - else: - axisValues[name].append(value) - have = self.getAxisOrder() - for name, values in axisValues.items(): - a = None - if name in have: - if overwrite: - # we have the axis, - a = self.getAxis(name) - else: - continue - else: - # we need to make this axis - a = self.newAxisDescriptor() - self.addAxis(a) - a.name = name - a.minimum = min(values) - a.maximum = max(values) - a.default = a.minimum - a.tag, a.labelNames = tagForAxisName(a.name) - self.logger.info("CheckAxes: added a missing axis %s, %3.3f %3.3f", a.name, a.minimum, a.maximum) - - - def normalizeLocation(self, location): - # scale this location based on the axes - # accept only values for the axes that we have definitions for - # only normalise if we're valid? - # normalise anisotropic cooordinates to isotropic. - # copied from fontTools.varlib.models.normalizeLocation - new = {} - for axis in self.axes: - if not axis.name in location: - # skipping this dimension it seems - continue - v = location.get(axis.name, axis.default) - if type(v)==tuple: - v = v[0] - if v == axis.default: - v = 0.0 - elif v < axis.default: - if axis.default == axis.minimum: - v = 0.0 - else: - v = (max(v, axis.minimum) - axis.default) / (axis.default - axis.minimum) - else: - if axis.default == axis.maximum: - v = 0.0 - else: - v = (min(v, axis.maximum) - axis.default) / (axis.maximum - axis.default) - new[axis.name] = v - return new - - def normalize(self): - # scale all the locations of all masters and instances to the -1 - 0 - 1 value. - # we need the axis data to do the scaling, so we do those last. - # masters - for item in self.sources: - item.location = self.normalizeLocation(item.location) - # instances - for item in self.instances: - # glyph masters for this instance - for name, glyphData in item.glyphs.items(): - glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation']) - for glyphMaster in glyphData['masters']: - glyphMaster['location'] = self.normalizeLocation(glyphMaster['location']) - item.location = self.normalizeLocation(item.location) - # now the axes - for axis in self.axes: - # scale the map first - newMap = [] - for inputValue, outputValue in axis.map: - newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name) - newMap.append((inputValue, newOutputValue)) - if newMap: - axis.map = newMap - # finally the axis values - minimum = self.normalizeLocation({axis.name:axis.minimum}).get(axis.name) - maximum = self.normalizeLocation({axis.name:axis.maximum}).get(axis.name) - default = self.normalizeLocation({axis.name:axis.default}).get(axis.name) - # and set them in the axis.minimum - axis.minimum = minimum - axis.maximum = maximum - axis.default = default - # now the rules - for rule in self.rules: - newConditions = [] - for cond in rule.conditions: - if cond.get('minimum') is not None: - minimum = self.normalizeLocation({cond['name']:cond['minimum']}).get(cond['name']) - else: - minimum = None - if cond.get('maximum') is not None: - maximum = self.normalizeLocation({cond['name']:cond['maximum']}).get(cond['name']) - else: - maximum = None - newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum)) - rule.conditions = newConditions - - -class InMemorySourceDescriptor(SourceDescriptor): - """A source descriptor that has a reference to an instance of a defcon Font - loaded in memory. - """ - - def __init__(self, fontClass=None): - self._font = None - super(InMemorySourceDescriptor, self).__init__() - if fontClass is not None: - self.fontClass = fontClass - else: - from defcon.objects.font import Font - self.fontClass = Font - - @property - def font(self): - if self._font is not None: - return self._font - - if self.path: - self._font = self.fontClass(self.path) - elif self.document and self.filename: - path = os.path.join(os.path.dirname(self.document), self.filename) - self._font = self.fontClass(path) - - if self._font is None: - raise NoFontError("") - - return self._font - - @font.setter - def font(self, font): - self._font = font - # FIXME: (jany) not sure the following is correct - # Intent: if the given font already has a path, use it - # else write the font next to the path specified for the descriptor - # else write the font next to the designSpaceDocument. - # else use a default path relative to the descriptor. - if font.path: - self.path = font.path - self.filename = None - # No need to compute the correct relative filename - - # The font's path is not updated here. Instead, a correct value - # will be computed by the writer, depending on where the whole - # document is going to be written. - - -class InMemoryDocWriter(BaseDocWriter): - """Writes in-memory UFOs next to the designspace.""" - - sourceDescriptorClass = InMemorySourceDescriptor - - @classmethod - def getSourceDescriptor(cls, document): - # FIXME: (jany) settle on whether we want - # 1. descriptors to hold the fontClass - # 2. descriptors to refer to their parent document - # 3. another design (back to "dumb data bag" descriptors, no OOP) - return cls.sourceDescriptorClass(fontClass=document.fontClass) - - def write(self, pretty=True): - super(InMemoryDocWriter, self).write(pretty) - # FIXME: (jany) think about a way of reliably writing the document and - # the UFOs next to each other in one function call - # for sourceObject in self.documentObject.sources: - # if not sourceObject.filename: - # self.documentObject.logger.warn( - # 'In-memory source font {font} not written to the disk ' - # 'because its descriptor does not have a filename.'.format( - # font=sourceObject.font)) - # continue - # path = os.path.join( - # os.path.dirname(self.path), sourceObject.filename) - # sourceObject.font.save(path) - - -def rulesToFeature(doc, whiteSpace="\t", newLine="\n"): - """ Showing how rules could be expressed as FDK feature text. - Speculative. Experimental. - """ - axisNames = {axis.name: axis.tag for axis in doc.axes} - axisDims = {axis.tag: (axis.minimum, axis.maximum) for axis in doc.axes} - text = [] - for rule in doc.rules: - text.append("rule %s{"%rule.name) - for cd in rule.conditions: - axisTag = axisNames.get(cd.get('name'), "****") - axisMinimum = cd.get('minimum', axisDims.get(axisTag, [0,0])[0]) - axisMaximum = cd.get('maximum', axisDims.get(axisTag, [0,0])[1]) - text.append("%s%s %f %f;"%(whiteSpace, axisTag, axisMinimum, axisMaximum)) - text.append("} %s;"%rule.name) - return newLine.join(text) - -if __name__ == "__main__": - - def __removeAxesFromDesignSpace(path): - # only for testing, so we can make an invalid designspace file - # without making the designSpaceDocument also support it. - f = open(path, 'r') - d = f.read() - f.close() - start = d.find("") - end = d.find("")+len("") - n = d[0:start] + d[end:] - f = open(path, 'w') - f.write(n) - f.close() - - def test(): - u""" - >>> import os - >>> testDocPath = os.path.join(os.getcwd(), "test.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) - >>> s1.name = "master.ufo1" - >>> s1.copyLib = True - >>> s1.copyInfo = True - >>> s1.copyFeatures = True - >>> s1.location = dict(weight=0) - >>> s1.familyName = "MasterFamilyName" - >>> s1.styleName = "MasterStyleNameOne" - >>> s1.mutedGlyphNames.append("A") - >>> s1.mutedGlyphNames.append("Z") - >>> doc.addSource(s1) - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) - >>> s2.name = "master.ufo2" - >>> s2.copyLib = False - >>> s2.copyInfo = False - >>> s2.copyFeatures = False - >>> s2.muteKerning = True - >>> s2.location = dict(weight=1000) - >>> s2.familyName = "MasterFamilyName" - >>> s2.styleName = "MasterStyleNameTwo" - >>> doc.addSource(s2) - >>> # add instance 1 - >>> i1 = InstanceDescriptor() - >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) - >>> i1.familyName = "InstanceFamilyName" - >>> i1.styleName = "InstanceStyleName" - >>> i1.name = "instance.ufo1" - >>> i1.location = dict(weight=500, spooky=666) # this adds a dimension that is not defined. - >>> i1.postScriptFontName = "InstancePostscriptName" - >>> i1.styleMapFamilyName = "InstanceStyleMapFamilyName" - >>> i1.styleMapStyleName = "InstanceStyleMapStyleName" - >>> glyphData = dict(name="arrow", mute=True, unicodes=[0x123, 0x124, 0x125]) - >>> i1.glyphs['arrow'] = glyphData - >>> doc.addInstance(i1) - >>> # add instance 2 - >>> i2 = InstanceDescriptor() - >>> i2.filename = os.path.relpath(instancePath2, os.path.dirname(testDocPath)) - >>> i2.familyName = "InstanceFamilyName" - >>> i2.styleName = "InstanceStyleName" - >>> i2.name = "instance.ufo2" - >>> # anisotropic location - >>> i2.location = dict(weight=500, width=(400,300)) - >>> i2.postScriptFontName = "InstancePostscriptName" - >>> i2.styleMapFamilyName = "InstanceStyleMapFamilyName" - >>> i2.styleMapStyleName = "InstanceStyleMapStyleName" - >>> glyphMasters = [dict(font="master.ufo1", glyphName="BB", location=dict(width=20,weight=20)), dict(font="master.ufo2", glyphName="CC", location=dict(width=900,weight=900))] - >>> glyphData = dict(name="arrow", unicodes=[101, 201, 301]) - >>> glyphData['masters'] = glyphMasters - >>> glyphData['note'] = "A note about this glyph" - >>> glyphData['instanceLocation'] = dict(width=100, weight=120) - >>> i2.glyphs['arrow'] = glyphData - >>> i2.glyphs['arrow2'] = dict(mute=False) - >>> doc.addInstance(i2) - >>> # now we have sources and instances, but no axes yet. - >>> doc.check() - >>> doc.getAxisOrder() - ['spooky', 'weight', 'width'] - >>> doc.axes = [] # clear the axes - >>> # write some axes - >>> a1 = AxisDescriptor() - >>> a1.minimum = 0 - >>> a1.maximum = 1000 - >>> a1.default = 0 - >>> a1.name = "weight" - >>> a1.tag = "wght" - >>> # note: just to test the element language, not an actual label name recommendations. - >>> a1.labelNames[u'fa-IR'] = u"قطر" - >>> a1.labelNames[u'en'] = u"Wéíght" - >>> doc.addAxis(a1) - >>> a2 = AxisDescriptor() - >>> a2.minimum = 0 - >>> a2.maximum = 1000 - >>> a2.default = 20 - >>> a2.name = "width" - >>> a2.tag = "wdth" - >>> a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] - >>> a2.hidden = True - >>> a2.labelNames[u'fr'] = u"Poids" - >>> doc.addAxis(a2) - >>> # add an axis that is not part of any location to see if that works - >>> a3 = AxisDescriptor() - >>> a3.minimum = 333 - >>> a3.maximum = 666 - >>> a3.default = 444 - >>> a3.name = "spooky" - >>> a3.tag = "spok" - >>> a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] - >>> #doc.addAxis(a3) # uncomment this line to test the effects of default axes values - >>> # write some rules - >>> r1 = RuleDescriptor() - >>> r1.name = "named.rule.1" - >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1)) - >>> r1.conditions.append(dict(name='bbbb', minimum=2, maximum=3)) - >>> r1.subs.append(("a", "a.alt")) - >>> doc.addRule(r1) - >>> # write the document - >>> doc.write(testDocPath) - >>> assert os.path.exists(testDocPath) - >>> # import it again - >>> new = DesignSpaceDocument() - >>> new.read(testDocPath) - >>> new.check() - >>> new.default.location - {'width': 20.0, 'weight': 0.0} - - # >>> for a, b in zip(doc.instances, new.instances): - # ... a.compare(b) - # >>> for a, b in zip(doc.sources, new.sources): - # ... a.compare(b) - # >>> for a, b in zip(doc.axes, new.axes): - # ... a.compare(b) - # >>> [n.mutedGlyphNames for n in new.sources] - # [['A', 'Z'], []] - # >>> doc.getFonts() - # [] - - >>> # test roundtrip for the axis attributes and data - >>> axes = {} - >>> for axis in doc.axes: - ... if not axis.tag in axes: - ... axes[axis.tag] = [] - ... axes[axis.tag].append(axis.serialize()) - >>> for axis in new.axes: - ... if axis.tag[0] == "_": continue - ... if not axis.tag in axes: - ... axes[axis.tag] = [] - ... axes[axis.tag].append(axis.serialize()) - >>> for v in axes.values(): - ... a, b = v - ... assert a == b - - """ - - def testAdjustAxisDefaultToNeutral(): - u""" - >>> import os - >>> testDocPath = os.path.join(os.getcwd(), "testAdjustAxisDefaultToNeutral.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) - >>> s1.name = "master.ufo1" - >>> s1.copyInfo = True - >>> s1.copyFeatures = True - >>> s1.location = dict(weight=55, width=1000) - >>> doc.addSource(s1) - >>> # write some axes - >>> a1 = AxisDescriptor() - >>> a1.minimum = 0 - >>> a1.maximum = 1000 - >>> a1.default = 0 # the wrong value - >>> a1.name = "weight" - >>> a1.tag = "wght" - >>> doc.addAxis(a1) - >>> a2 = AxisDescriptor() - >>> a2.minimum = -10 - >>> a2.maximum = 10 - >>> a2.default = 0 # the wrong value - >>> a2.name = "width" - >>> a2.tag = "wdth" - >>> doc.addAxis(a2) - >>> # write the document - >>> doc.write(testDocPath) - >>> assert os.path.exists(testDocPath) - >>> # import it again - >>> new = DesignSpaceDocument() - >>> new.read(testDocPath) - >>> new.check() - >>> loc = new.default.location - >>> for axisObj in new.axes: - ... n = axisObj.name - ... assert axisObj.default == loc.get(n) - """ - - def testUnicodes(): - u""" - >>> import os - >>> testDocPath = os.path.join(os.getcwd(), "testUnicodes.designspace") - >>> testDocPath2 = os.path.join(os.getcwd(), "testUnicodes_roundtrip.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) - >>> s1.name = "master.ufo1" - >>> s1.copyInfo = True - >>> s1.location = dict(weight=0) - >>> doc.addSource(s1) - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) - >>> s2.name = "master.ufo2" - >>> s2.location = dict(weight=1000) - >>> doc.addSource(s2) - >>> # add instance 1 - >>> i1 = InstanceDescriptor() - >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) - >>> i1.name = "instance.ufo1" - >>> i1.location = dict(weight=500) - >>> glyphData = dict(name="arrow", mute=True, unicodes=[100, 200, 300]) - >>> i1.glyphs['arrow'] = glyphData - >>> doc.addInstance(i1) - >>> # now we have sources and instances, but no axes yet. - >>> doc.axes = [] # clear the axes - >>> # write some axes - >>> a1 = AxisDescriptor() - >>> a1.minimum = 0 - >>> a1.maximum = 1000 - >>> a1.default = 0 - >>> a1.name = "weight" - >>> a1.tag = "wght" - >>> doc.addAxis(a1) - >>> # write the document - >>> doc.write(testDocPath) - >>> assert os.path.exists(testDocPath) - >>> # import it again - >>> new = DesignSpaceDocument() - >>> new.read(testDocPath) - >>> new.write(testDocPath2) - >>> # compare the file contents - >>> f1 = open(testDocPath, 'r') - >>> t1 = f1.read() - >>> f1.close() - >>> f2 = open(testDocPath2, 'r') - >>> t2 = f2.read() - >>> f2.close() - >>> t1 == t2 - True - >>> # check the unicode values read from the document - >>> new.instances[0].glyphs['arrow']['unicodes'] == [100,200,300] - True - """ - - def testLocalisedNames(): - u""" - >>> import os - >>> testDocPath = os.path.join(os.getcwd(), "testLocalisedNames.designspace") - >>> testDocPath2 = os.path.join(os.getcwd(), "testLocalisedNames_roundtrip.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) - >>> s1.name = "master.ufo1" - >>> s1.copyInfo = True - >>> s1.location = dict(weight=0) - >>> doc.addSource(s1) - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) - >>> s2.name = "master.ufo2" - >>> s2.location = dict(weight=1000) - >>> doc.addSource(s2) - >>> # add instance 1 - >>> i1 = InstanceDescriptor() - >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) - >>> i1.familyName = "Montserrat" - >>> i1.styleName = "SemiBold" - >>> i1.styleMapFamilyName = "Montserrat SemiBold" - >>> i1.styleMapStyleName = "Regular" - >>> i1.setFamilyName("Montserrat", "fr") - >>> i1.setFamilyName(u"モンセラート", "ja") - >>> i1.setStyleName("Demigras", "fr") - >>> i1.setStyleName(u"半ば", "ja") - >>> i1.setStyleMapStyleName(u"Standard", "de") - >>> i1.setStyleMapFamilyName("Montserrat Halbfett", "de") - >>> i1.setStyleMapFamilyName(u"モンセラート SemiBold", "ja") - >>> i1.name = "instance.ufo1" - >>> i1.location = dict(weight=500, spooky=666) # this adds a dimension that is not defined. - >>> i1.postScriptFontName = "InstancePostscriptName" - >>> glyphData = dict(name="arrow", mute=True, unicodes=[0x123]) - >>> i1.glyphs['arrow'] = glyphData - >>> doc.addInstance(i1) - >>> # now we have sources and instances, but no axes yet. - >>> doc.axes = [] # clear the axes - >>> # write some axes - >>> a1 = AxisDescriptor() - >>> a1.minimum = 0 - >>> a1.maximum = 1000 - >>> a1.default = 0 - >>> a1.name = "weight" - >>> a1.tag = "wght" - >>> # note: just to test the element language, not an actual label name recommendations. - >>> a1.labelNames[u'fa-IR'] = u"قطر" - >>> a1.labelNames[u'en'] = u"Wéíght" - >>> doc.addAxis(a1) - >>> a2 = AxisDescriptor() - >>> a2.minimum = 0 - >>> a2.maximum = 1000 - >>> a2.default = 0 - >>> a2.name = "width" - >>> a2.tag = "wdth" - >>> a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] - >>> a2.labelNames[u'fr'] = u"Poids" - >>> doc.addAxis(a2) - >>> # add an axis that is not part of any location to see if that works - >>> a3 = AxisDescriptor() - >>> a3.minimum = 333 - >>> a3.maximum = 666 - >>> a3.default = 444 - >>> a3.name = "spooky" - >>> a3.tag = "spok" - >>> a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] - >>> #doc.addAxis(a3) # uncomment this line to test the effects of default axes values - >>> # write some rules - >>> r1 = RuleDescriptor() - >>> r1.name = "named.rule.1" - >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1)) - >>> r1.conditions.append(dict(name='bbbb', minimum=2, maximum=3)) - >>> r1.subs.append(("a", "a.alt")) - >>> doc.addRule(r1) - >>> # write the document - >>> doc.write(testDocPath) - >>> assert os.path.exists(testDocPath) - >>> # import it again - >>> new = DesignSpaceDocument() - >>> new.read(testDocPath) - >>> new.write(testDocPath2) - >>> f1 = open(testDocPath, 'r') - >>> t1 = f1.read() - >>> f1.close() - >>> f2 = open(testDocPath2, 'r') - >>> t2 = f2.read() - >>> f2.close() - >>> assert t1 == t2 - - """ - - def testHandleNoAxes(): - # test what happens if the designspacedocument has no axes element. - """ - >>> import os - >>> testDocPath = os.path.join(os.getcwd(), "testNoAxes_source.designspace") - >>> testDocPath2 = os.path.join(os.getcwd(), "testNoAxes_recontructed.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - - # Case 1: No axes element in the document, but there are sources and instances - >>> doc = DesignSpaceDocument() - - >>> for name, value in [('One', 1),('Two', 2),('Three', 3)]: - ... a = AxisDescriptor() - ... a.minimum = 0 - ... a.maximum = 1000 - ... a.default = 0 - ... a.name = "axisName%s"%(name) - ... a.tag = "ax_%d"%(value) - ... doc.addAxis(a) - - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) - >>> s1.name = "master.ufo1" - >>> s1.copyLib = True - >>> s1.copyInfo = True - >>> s1.copyFeatures = True - >>> s1.location = dict(axisNameOne=-1000, axisNameTwo=0, axisNameThree=1000) - >>> s1.familyName = "MasterFamilyName" - >>> s1.styleName = "MasterStyleNameOne" - >>> doc.addSource(s1) - - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) - >>> s2.name = "master.ufo1" - >>> s2.copyLib = False - >>> s2.copyInfo = False - >>> s2.copyFeatures = False - >>> s2.location = dict(axisNameOne=1000, axisNameTwo=1000, axisNameThree=0) - >>> s2.familyName = "MasterFamilyName" - >>> s2.styleName = "MasterStyleNameTwo" - >>> doc.addSource(s2) - - >>> # add instance 1 - >>> i1 = InstanceDescriptor() - >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) - >>> i1.familyName = "InstanceFamilyName" - >>> i1.styleName = "InstanceStyleName" - >>> i1.name = "instance.ufo1" - >>> i1.location = dict(axisNameOne=(-1000,500), axisNameTwo=100) - >>> i1.postScriptFontName = "InstancePostscriptName" - >>> i1.styleMapFamilyName = "InstanceStyleMapFamilyName" - >>> i1.styleMapStyleName = "InstanceStyleMapStyleName" - >>> doc.addInstance(i1) - - >>> doc.write(testDocPath) - >>> __removeAxesFromDesignSpace(testDocPath) - >>> verify = DesignSpaceDocument() - >>> verify.read(testDocPath) - >>> verify.write(testDocPath2) - """ - - def testPathNameResolve(): - # test how descriptor.path and descriptor.filename are resolved - """ - >>> import os - >>> testDocPath1 = os.path.join(os.getcwd(), "testPathName_case1.designspace") - >>> testDocPath2 = os.path.join(os.getcwd(), "testPathName_case2.designspace") - >>> testDocPath3 = os.path.join(os.getcwd(), "testPathName_case3.designspace") - >>> testDocPath4 = os.path.join(os.getcwd(), "testPathName_case4.designspace") - >>> testDocPath5 = os.path.join(os.getcwd(), "testPathName_case5.designspace") - >>> testDocPath6 = os.path.join(os.getcwd(), "testPathName_case6.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - - # Case 1: filename and path are both empty. Nothing to calculate, nothing to put in the file. - >>> doc = DesignSpaceDocument() - >>> s = SourceDescriptor() - >>> s.filename = None - >>> s.path = None - >>> s.copyInfo = True - >>> s.location = dict(weight=0) - >>> s.familyName = "MasterFamilyName" - >>> s.styleName = "MasterStyleNameOne" - >>> doc.addSource(s) - >>> doc.write(testDocPath1) - >>> verify = DesignSpaceDocument() - >>> verify.read(testDocPath1) - >>> assert verify.sources[0].filename == None - >>> assert verify.sources[0].path == None - - # Case 2: filename is empty, path points somewhere: calculate a new filename. - >>> doc = DesignSpaceDocument() - >>> s = SourceDescriptor() - >>> s.filename = None - >>> s.path = masterPath1 - >>> s.copyInfo = True - >>> s.location = dict(weight=0) - >>> s.familyName = "MasterFamilyName" - >>> s.styleName = "MasterStyleNameOne" - >>> doc.addSource(s) - >>> doc.write(testDocPath2) - >>> verify = DesignSpaceDocument() - >>> verify.read(testDocPath2) - >>> assert verify.sources[0].filename == "masters/masterTest1.ufo" - >>> assert verify.sources[0].path == masterPath1 - - # Case 3: the filename is set, the path is None. - >>> doc = DesignSpaceDocument() - >>> s = SourceDescriptor() - >>> s.filename = "../somewhere/over/the/rainbow.ufo" - >>> s.path = None - >>> s.copyInfo = True - >>> s.location = dict(weight=0) - >>> s.familyName = "MasterFamilyName" - >>> s.styleName = "MasterStyleNameOne" - >>> doc.addSource(s) - >>> doc.write(testDocPath3) - >>> verify = DesignSpaceDocument() - >>> verify.read(testDocPath3) - >>> assert verify.sources[0].filename == "../somewhere/over/the/rainbow.ufo" - >>> # make the absolute path for filename so we can see if it matches the path - >>> p = os.path.abspath(os.path.join(os.path.dirname(testDocPath3), verify.sources[0].filename)) - >>> assert verify.sources[0].path == p - - # Case 4: the filename points to one file, the path points to another. The path takes precedence. - >>> doc = DesignSpaceDocument() - >>> s = SourceDescriptor() - >>> s.filename = "../somewhere/over/the/rainbow.ufo" - >>> s.path = masterPath1 - >>> s.copyInfo = True - >>> s.location = dict(weight=0) - >>> s.familyName = "MasterFamilyName" - >>> s.styleName = "MasterStyleNameOne" - >>> doc.addSource(s) - >>> doc.write(testDocPath4) - >>> verify = DesignSpaceDocument() - >>> verify.read(testDocPath4) - >>> assert verify.sources[0].filename == "masters/masterTest1.ufo" - - # Case 5: the filename is None, path has a value, update the filename - >>> doc = DesignSpaceDocument() - >>> s = SourceDescriptor() - >>> s.filename = None - >>> s.path = masterPath1 - >>> s.copyInfo = True - >>> s.location = dict(weight=0) - >>> s.familyName = "MasterFamilyName" - >>> s.styleName = "MasterStyleNameOne" - >>> doc.addSource(s) - >>> doc.write(testDocPath5) # so that the document has a path - >>> doc.updateFilenameFromPath() - >>> assert doc.sources[0].filename == "masters/masterTest1.ufo" - - # Case 6: the filename has a value, path has a value, update the filenames with force - >>> doc = DesignSpaceDocument() - >>> s = SourceDescriptor() - >>> s.filename = "../somewhere/over/the/rainbow.ufo" - >>> s.path = masterPath1 - >>> s.copyInfo = True - >>> s.location = dict(weight=0) - >>> s.familyName = "MasterFamilyName" - >>> s.styleName = "MasterStyleNameOne" - >>> doc.write(testDocPath5) # so that the document has a path - >>> doc.addSource(s) - >>> assert doc.sources[0].filename == "../somewhere/over/the/rainbow.ufo" - >>> doc.updateFilenameFromPath(force=True) - >>> assert doc.sources[0].filename == "masters/masterTest1.ufo" - - """ - - def testNormalise(): - """ - >>> doc = DesignSpaceDocument() - >>> # write some axes - >>> a1 = AxisDescriptor() - >>> a1.minimum = -1000 - >>> a1.maximum = 1000 - >>> a1.default = 0 - >>> a1.name = "aaa" - >>> a1.tag = "aaaa" - >>> doc.addAxis(a1) - - >>> doc.normalizeLocation(dict(aaa=0)) - {'aaa': 0.0} - >>> doc.normalizeLocation(dict(aaa=1000)) - {'aaa': 1.0} - >>> # clipping beyond max values: - >>> doc.normalizeLocation(dict(aaa=1001)) - {'aaa': 1.0} - >>> doc.normalizeLocation(dict(aaa=500)) - {'aaa': 0.5} - >>> doc.normalizeLocation(dict(aaa=-1000)) - {'aaa': -1.0} - >>> doc.normalizeLocation(dict(aaa=-1001)) - {'aaa': -1.0} - >>> # anisotropic coordinates normalise to isotropic - >>> doc.normalizeLocation(dict(aaa=(1000,-1000))) - {'aaa': 1.0} - >>> doc.normalize() - >>> r = [] - >>> for axis in doc.axes: - ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) - >>> r.sort() - >>> r - [('aaa', -1.0, 0.0, 1.0)] - - >>> doc = DesignSpaceDocument() - >>> # write some axes - >>> a2 = AxisDescriptor() - >>> a2.minimum = 100 - >>> a2.maximum = 1000 - >>> a2.default = 100 - >>> a2.name = "bbb" - >>> doc.addAxis(a2) - >>> doc.normalizeLocation(dict(bbb=0)) - {'bbb': 0.0} - >>> doc.normalizeLocation(dict(bbb=1000)) - {'bbb': 1.0} - >>> # clipping beyond max values: - >>> doc.normalizeLocation(dict(bbb=1001)) - {'bbb': 1.0} - >>> doc.normalizeLocation(dict(bbb=500)) - {'bbb': 0.4444444444444444} - >>> doc.normalizeLocation(dict(bbb=-1000)) - {'bbb': 0.0} - >>> doc.normalizeLocation(dict(bbb=-1001)) - {'bbb': 0.0} - >>> # anisotropic coordinates normalise to isotropic - >>> doc.normalizeLocation(dict(bbb=(1000,-1000))) - {'bbb': 1.0} - >>> doc.normalizeLocation(dict(bbb=1001)) - {'bbb': 1.0} - >>> doc.normalize() - >>> r = [] - >>> for axis in doc.axes: - ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) - >>> r.sort() - >>> r - [('bbb', 0.0, 0.0, 1.0)] - - >>> doc = DesignSpaceDocument() - >>> # write some axes - >>> a3 = AxisDescriptor() - >>> a3.minimum = -1000 - >>> a3.maximum = 0 - >>> a3.default = 0 - >>> a3.name = "ccc" - >>> doc.addAxis(a3) - >>> doc.normalizeLocation(dict(ccc=0)) - {'ccc': 0.0} - >>> doc.normalizeLocation(dict(ccc=1)) - {'ccc': 0.0} - >>> doc.normalizeLocation(dict(ccc=-1000)) - {'ccc': -1.0} - >>> doc.normalizeLocation(dict(ccc=-1001)) - {'ccc': -1.0} - - >>> doc.normalize() - >>> r = [] - >>> for axis in doc.axes: - ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) - >>> r.sort() - >>> r - [('ccc', -1.0, 0.0, 0.0)] - - - >>> doc = DesignSpaceDocument() - >>> # write some axes - >>> a3 = AxisDescriptor() - >>> a3.minimum = 2000 - >>> a3.maximum = 3000 - >>> a3.default = 2000 - >>> a3.name = "ccc" - >>> doc.addAxis(a3) - >>> doc.normalizeLocation(dict(ccc=0)) - {'ccc': 0.0} - >>> doc.normalizeLocation(dict(ccc=1)) - {'ccc': 0.0} - >>> doc.normalizeLocation(dict(ccc=-1000)) - {'ccc': 0.0} - >>> doc.normalizeLocation(dict(ccc=-1001)) - {'ccc': 0.0} - - >>> doc.normalize() - >>> r = [] - >>> for axis in doc.axes: - ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) - >>> r.sort() - >>> r - [('ccc', 0.0, 0.0, 1.0)] - - - >>> doc = DesignSpaceDocument() - >>> # write some axes - >>> a4 = AxisDescriptor() - >>> a4.minimum = 0 - >>> a4.maximum = 1000 - >>> a4.default = 0 - >>> a4.name = "ddd" - >>> a4.map = [(0,100), (300, 500), (600, 500), (1000,900)] - >>> doc.addAxis(a4) - >>> doc.normalize() - >>> r = [] - >>> for axis in doc.axes: - ... r.append((axis.name, axis.map)) - >>> r.sort() - >>> r - [('ddd', [(0, 0.1), (300, 0.5), (600, 0.5), (1000, 0.9)])] - - - """ - - def testCheck(): - """ - >>> # check if the checks are checking - >>> testDocPath = os.path.join(os.getcwd(), "testCheck.designspace") - >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") - >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") - >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") - >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") - - >>> # no default selected - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.path = masterPath1 - >>> s1.name = "master.ufo1" - >>> s1.location = dict(snap=0, pop=10) - >>> s1.familyName = "MasterFamilyName" - >>> s1.styleName = "MasterStyleNameOne" - >>> doc.addSource(s1) - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.path = masterPath2 - >>> s2.name = "master.ufo2" - >>> s2.location = dict(snap=1000, pop=20) - >>> s2.familyName = "MasterFamilyName" - >>> s2.styleName = "MasterStyleNameTwo" - >>> doc.addSource(s2) - >>> doc.checkAxes() - >>> doc.getAxisOrder() - ['snap', 'pop'] - >>> assert doc.default == None - >>> doc.checkDefault() - >>> doc.default.name - 'master.ufo1' - - >>> # default selected - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.path = masterPath1 - >>> s1.name = "master.ufo1" - >>> s1.location = dict(snap=0, pop=10) - >>> s1.familyName = "MasterFamilyName" - >>> s1.styleName = "MasterStyleNameOne" - >>> doc.addSource(s1) - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.path = masterPath2 - >>> s2.name = "master.ufo2" - >>> s2.copyInfo = True - >>> s2.location = dict(snap=1000, pop=20) - >>> s2.familyName = "MasterFamilyName" - >>> s2.styleName = "MasterStyleNameTwo" - >>> doc.addSource(s2) - >>> doc.checkAxes() - >>> doc.getAxisOrder() - ['snap', 'pop'] - >>> assert doc.default == None - >>> doc.checkDefault() - >>> doc.default.name - 'master.ufo2' - - >>> # generate a doc without axes, save and read again - >>> doc = DesignSpaceDocument() - >>> # add master 1 - >>> s1 = SourceDescriptor() - >>> s1.path = masterPath1 - >>> s1.name = "master.ufo1" - >>> s1.location = dict(snap=0, pop=10) - >>> s1.familyName = "MasterFamilyName" - >>> s1.styleName = "MasterStyleNameOne" - >>> doc.addSource(s1) - >>> # add master 2 - >>> s2 = SourceDescriptor() - >>> s2.path = masterPath2 - >>> s2.name = "master.ufo2" - >>> s2.location = dict(snap=1000, pop=20) - >>> s2.familyName = "MasterFamilyName" - >>> s2.styleName = "MasterStyleNameTwo" - >>> doc.addSource(s2) - >>> doc.checkAxes() - >>> doc.write(testDocPath) - >>> __removeAxesFromDesignSpace(testDocPath) - - >>> new = DesignSpaceDocument() - >>> new.read(testDocPath) - >>> len(new.axes) - 2 - >>> new.checkAxes() - >>> len(new.axes) - 2 - >>> print([a.name for a in new.axes]) - ['snap', 'pop'] - >>> new.write(testDocPath) - - """ - - def testRules(): - """ - >>> import os - >>> testDocPath = os.path.join(os.getcwd(), "testRules.designspace") - >>> testDocPath2 = os.path.join(os.getcwd(), "testRules_roundtrip.designspace") - >>> doc = DesignSpaceDocument() - >>> # write some axes - >>> a1 = AxisDescriptor() - >>> a1.tag = "taga" - >>> a1.name = "aaaa" - >>> a1.minimum = 0 - >>> a1.maximum = 1000 - >>> a1.default = 0 - >>> doc.addAxis(a1) - >>> a2 = AxisDescriptor() - >>> a2.tag = "tagb" - >>> a2.name = "bbbb" - >>> a2.minimum = 0 - >>> a2.maximum = 3000 - >>> a2.default = 0 - >>> doc.addAxis(a2) - - >>> r1 = RuleDescriptor() - >>> r1.name = "named.rule.1" - >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1000)) - >>> r1.conditions.append(dict(name='bbbb', minimum=0, maximum=3000)) - >>> r1.subs.append(("a", "a.alt")) - >>> - >>> # rule with minium and maximum - >>> doc.addRule(r1) - >>> assert len(doc.rules) == 1 - >>> assert len(doc.rules[0].conditions) == 2 - >>> evaluateRule(r1, dict(aaaa = 500, bbbb = 0)) - True - >>> evaluateRule(r1, dict(aaaa = 0, bbbb = 0)) - True - >>> evaluateRule(r1, dict(aaaa = 1000, bbbb = 0)) - True - >>> evaluateRule(r1, dict(aaaa = 1000, bbbb = -100)) - False - >>> evaluateRule(r1, dict(aaaa = 1000.0001, bbbb = 0)) - False - >>> evaluateRule(r1, dict(aaaa = -0.0001, bbbb = 0)) - False - >>> evaluateRule(r1, dict(aaaa = -100, bbbb = 0)) - False - >>> processRules([r1], dict(aaaa = 500), ["a", "b", "c"]) - ['a.alt', 'b', 'c'] - >>> processRules([r1], dict(aaaa = 500), ["a.alt", "b", "c"]) - ['a.alt', 'b', 'c'] - >>> processRules([r1], dict(aaaa = 2000), ["a", "b", "c"]) - ['a', 'b', 'c'] - - >>> # rule with only a maximum - >>> r2 = RuleDescriptor() - >>> r2.name = "named.rule.2" - >>> r2.conditions.append(dict(name='aaaa', maximum=500)) - >>> r2.subs.append(("b", "b.alt")) - >>> - >>> evaluateRule(r2, dict(aaaa = 0)) - True - >>> evaluateRule(r2, dict(aaaa = -500)) - True - >>> evaluateRule(r2, dict(aaaa = 1000)) - False - - >>> # rule with only a minimum - >>> r3 = RuleDescriptor() - >>> r3.name = "named.rule.3" - >>> r3.conditions.append(dict(name='aaaa', minimum=500)) - >>> r3.subs.append(("c", "c.alt")) - >>> - >>> evaluateRule(r3, dict(aaaa = 0)) - False - >>> evaluateRule(r3, dict(aaaa = 1000)) - True - >>> evaluateRule(r3, dict(bbbb = 1000)) - True - - >>> # rule with only a minimum, maximum in separate conditions - >>> r4 = RuleDescriptor() - >>> r4.name = "named.rule.4" - >>> r4.conditions.append(dict(name='aaaa', minimum=500)) - >>> r4.conditions.append(dict(name='bbbb', maximum=500)) - >>> r4.subs.append(("c", "c.alt")) - >>> - >>> evaluateRule(r4, dict()) # is this what we expect though? - True - >>> evaluateRule(r4, dict(aaaa = 1000, bbbb = 0)) - True - >>> evaluateRule(r4, dict(aaaa = 0, bbbb = 0)) - False - >>> evaluateRule(r4, dict(aaaa = 1000, bbbb = 1000)) - False - - >>> a1 = AxisDescriptor() - >>> a1.minimum = 0 - >>> a1.maximum = 1000 - >>> a1.default = 0 - >>> a1.name = "aaaa" - >>> a1.tag = "aaaa" - >>> b1 = AxisDescriptor() - >>> b1.minimum = 2000 - >>> b1.maximum = 3000 - >>> b1.default = 2000 - >>> b1.name = "bbbb" - >>> b1.tag = "bbbb" - >>> doc.addAxis(a1) - >>> doc.addAxis(b1) - >>> doc._prepAxesForBender() - {'aaaa': {'map': [], 'name': 'aaaa', 'default': 0, 'minimum': 0, 'maximum': 1000, 'tag': 'aaaa'}, 'bbbb': {'map': [], 'name': 'bbbb', 'default': 2000, 'minimum': 2000, 'maximum': 3000, 'tag': 'bbbb'}} - - - >>> doc.rules[0].conditions - [{'minimum': 0, 'maximum': 1000, 'name': 'aaaa'}, {'minimum': 0, 'maximum': 3000, 'name': 'bbbb'}] - - >>> doc.rules[0].subs - [('a', 'a.alt')] - - >>> doc.normalize() - >>> doc.rules[0].name - 'named.rule.1' - >>> doc.rules[0].conditions - [{'minimum': 0.0, 'maximum': 1.0, 'name': 'aaaa'}, {'minimum': 0.0, 'maximum': 1.0, 'name': 'bbbb'}] - - >>> doc.write(testDocPath) - >>> new = DesignSpaceDocument() - - >>> new.read(testDocPath) - >>> len(new.axes) - 4 - >>> len(new.rules) - 1 - >>> new.write(testDocPath2) - - """ - - p = "testCheck.designspace" - __removeAxesFromDesignSpace(p) - def _test(): - import doctest - doctest.testmod() - _test() 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 6cf7a49ff..f9549c6ba 100644 --- a/setup.py +++ b/setup.py @@ -180,7 +180,7 @@ def run(self): setup_requires=pytest_runner + wheel + bump2version, tests_require=test_requires, install_requires=[ - "fonttools>=3.22.0", + "fonttools>=3.24.0", "defcon>=0.3.0", "MutatorMath>=2.0.4", ], diff --git a/tests/builder/interpolation_test.py b/tests/builder/interpolation_test.py index 4df182bdb..957aed333 100644 --- a/tests/builder/interpolation_test.py +++ b/tests/builder/interpolation_test.py @@ -227,7 +227,7 @@ def test_fileName(self): self.expect_designspace_roundtrip(doc) def test_noRegularMaster(self): - # Currently, fonttools.varLib fails to build variable fonts + # 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. diff --git a/tests/builder/lib_and_user_data_test.py b/tests/builder/lib_and_user_data_test.py index 39bdda5a3..50497ec65 100644 --- a/tests/builder/lib_and_user_data_test.py +++ b/tests/builder/lib_and_user_data_test.py @@ -21,10 +21,9 @@ from collections import OrderedDict import defcon +from fontTools.designspaceLib import DesignSpaceDocument from glyphsLib import classes from glyphsLib.builder.constants import GLYPHLIB_PREFIX -from glyphsLib.designSpaceDocument import (DesignSpaceDocument, - InMemoryDocWriter) from glyphsLib import to_glyphs, to_ufos, to_designspace @@ -34,13 +33,13 @@ def test_designspace_lib_equivalent_to_font_user_data(tmpdir): - designspace = DesignSpaceDocument(writerClass=InMemoryDocWriter) + 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(writerClass=InMemoryDocWriter) + designspace = DesignSpaceDocument() designspace.read(path) font = to_glyphs(designspace) diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 9324900ff..6147eaa0f 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -27,8 +27,7 @@ from glyphsLib import to_glyphs, to_ufos, to_designspace from glyphsLib import classes -# FIXME: (jany) should come from fonttools -from glyphsLib.designSpaceDocument import DesignSpaceDocument +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 diff --git a/tests/run_various_tests_on_various_files.py b/tests/run_various_tests_on_various_files.py index 2ca516ccb..c14eb83c1 100644 --- a/tests/run_various_tests_on_various_files.py +++ b/tests/run_various_tests_on_various_files.py @@ -19,8 +19,7 @@ import re import glyphsLib -from glyphsLib.designSpaceDocument import (DesignSpaceDocument, - InMemoryDocWriter) +from fontTools.designspaceLib import DesignSpaceDocument import test_helpers diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f8f92e0dc..84a849acd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -26,8 +26,7 @@ import glyphsLib from glyphsLib import classes -from glyphsLib.designSpaceDocument import (DesignSpaceDocument, - InMemoryDocWriter) +from fontTools.designspaceLib import DesignSpaceDocument from glyphsLib.builder import to_glyphs, to_designspace from glyphsLib.writer import Writer from fontTools.misc.py23 import UnicodeIO @@ -116,8 +115,7 @@ def assertUFORoundtrip(self, font): directory = tempfile.mkdtemp() path = os.path.join(directory, font.familyName + '.designspace') write_designspace_and_UFOs(designspace, path) - designspace_roundtrip = DesignSpaceDocument( - writerClass=InMemoryDocWriter) + designspace_roundtrip = DesignSpaceDocument() designspace_roundtrip.read(path) roundtrip = to_glyphs(designspace_roundtrip) self._normalize(roundtrip) @@ -248,9 +246,9 @@ def assertDesignspaceRoundtrip(self, 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( + 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") From 3a09bf7fabc506b2479747e21e9b990d0ec25469 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 7 Mar 2018 12:19:19 +0000 Subject: [PATCH 36/44] Parse base64 encoded data in .glyphs --- Lib/glyphsLib/parser.py | 9 +++++++++ tests/parser_test.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/Lib/glyphsLib/parser.py b/Lib/glyphsLib/parser.py index 39c00de7f..3a2863f75 100644 --- a/Lib/glyphsLib/parser.py +++ b/Lib/glyphsLib/parser.py @@ -22,6 +22,7 @@ import re import logging import sys +import base64 import glyphsLib @@ -40,6 +41,7 @@ class Parser(object): list_delim_re = re.compile(r'\s*,') attr_re = re.compile(r'\s*%s\s*=' % value_re, re.DOTALL) value_re = re.compile(r'\s*%s' % value_re, re.DOTALL) + bytes_re = re.compile(r'\s*<([A-Za-z0-9+/=]+)>', re.DOTALL) def __init__(self, current_type=OrderedDict): self.current_type = current_type @@ -123,6 +125,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) diff --git a/tests/parser_test.py b/tests/parser_test.py index de695ca73..6dbc338b3 100644 --- a/tests/parser_test.py +++ b/tests/parser_test.py @@ -118,6 +118,12 @@ def test_parse_dict_in_dict(self): [('outer', OrderedDict([('inner', 'turtles')]))] ) + def test_parse_base64_data(self): + self.run_test( + b'{key = ;}', + [('key', b'value')] + ) + class ParserGlyphTest(unittest.TestCase): def test_parse_empty_glyphs(self): From 2034e9c9e94eca3b9eda14c9bef54a25f3193aad Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 7 Mar 2018 16:35:48 +0000 Subject: [PATCH 37/44] Handle in a basic way UFOs that have different feature files --- Lib/glyphsLib/builder/builders.py | 3 +- Lib/glyphsLib/builder/features.py | 79 +++++++++++++++++++++++++++++-- Lib/glyphsLib/builder/font.py | 1 - tests/builder/features_test.py | 47 ++++++++++++++++-- 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index aff057111..5945a0eed 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -197,10 +197,10 @@ def masters(self): ufo = source.font if self.propagate_anchors: self.to_ufo_propagate_font_anchors(ufo) - self.to_ufo_features(ufo) # This depends on the glyphOrder key for layer in ufo.layers: self.to_ufo_layer_lib(layer) + self.to_ufo_features() # This depends on the glyphOrder key self.to_ufo_groups() self.to_ufo_kerning() @@ -382,6 +382,7 @@ def font(self): for glyph in layer: self.to_glyphs_glyph(glyph, layer, master) + self.to_glyphs_features() self.to_glyphs_groups() self.to_glyphs_kerning() diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index af9477b57..c65055139 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -30,15 +30,28 @@ 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.""" + # 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 = [] @@ -166,7 +179,30 @@ def replace_feature(tag, repl, features): flags=re.DOTALL | re.MULTILINE) -def to_glyphs_features(self, ufo): +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: 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()) @@ -174,6 +210,40 @@ def to_glyphs_features(self, ufo): 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 + # only allowed deviation is 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): @@ -451,9 +521,8 @@ def _process_gdef_table_block(self): st = self.statements.peek() if not isinstance(st, ast.TableBlock) or st.name != 'GDEF': return False - self.statements.next() - # TODO: (jany) - return True + # TODO: read an existing GDEF table and do something with it? + return False def _pop_comment(self, statements, comment_re): """Look for the comment that matches the given regex. diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index 80cdb5857..febb52e04 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -146,7 +146,6 @@ def _set_glyphs_font_attributes(self, source): self.to_glyphs_family_names(ufo) self.to_glyphs_family_user_data_from_ufo(ufo) self.to_glyphs_custom_params(ufo, font) - self.to_glyphs_features(ufo) def _compare_and_merge_glyphs_font_attributes(self, source): diff --git a/tests/builder/features_test.py b/tests/builder/features_test.py index c0ab847d2..65e5e7256 100644 --- a/tests/builder/features_test.py +++ b/tests/builder/features_test.py @@ -232,6 +232,47 @@ def test_feature(tmpdir): assert rtufo.features.text.strip() == ufo.features.text.strip() -# TODO: add test with different features in different UFOs -# Assumption: all UFOs must have the same feature files, otherwise warning -# Use the feature file from the first UFO +def test_different_features_in_different_UFOS(tmpdir): + # If the input UFOs have different features, Glyphs cannot model the + # differences easily. + # + # TODO: 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 From 6623d32d4ff19a667fc757402373fbde5b6497f6 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 8 Mar 2018 18:25:51 +0000 Subject: [PATCH 38/44] Warn if the UFO and designspace names don't match --- Lib/glyphsLib/builder/builders.py | 45 ++++++++++++++++++++++++------- tests/builder/to_glyphs_test.py | 29 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 5945a0eed..0970b7d86 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -19,6 +19,7 @@ import logging import tempfile import os +from textwrap import dedent import defcon @@ -337,18 +338,9 @@ def __init__(self, self.minimize_ufo_diffs = minimize_ufo_diffs if designspace is not None: - self.designspace = designspace if ufos: raise NotImplementedError - for source in designspace.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) + self.designspace = self._valid_designspace(designspace) elif ufos: self.designspace = self._fake_designspace(ufos) else: @@ -409,6 +401,39 @@ def font(self): return self._font + 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: 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. diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 6147eaa0f..ad933f209 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -486,3 +486,32 @@ def test_only_background(): # 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' From e36097df50ee6184ad6d88f710bde27adfb3ebfe Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 8 Mar 2018 18:33:35 +0000 Subject: [PATCH 39/44] Keep custom styleMapStyleName from UFOs --- Lib/glyphsLib/builder/custom_params.py | 3 ++- Lib/glyphsLib/builder/names.py | 12 ------------ tests/builder/to_glyphs_test.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index c01ccb057..8ffe425c3 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -323,7 +323,8 @@ def register(handler): 'trademark', - 'styleMapFamilyName' + 'styleMapFamilyName', + 'styleMapStyleName', ) for name in GLYPHS_UFO_CUSTOM_PARAMS_NO_SHORT_NAME: register(ParamHandler(name)) diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index 12fd2944e..e368a6751 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -115,16 +115,4 @@ def to_glyphs_family_names(self, ufo, merge=False): def to_glyphs_master_names(self, ufo, master): - width = master.width - weight = master.weight - custom = master.customName - is_italic = bool(master.italicAngle) - - current_stylename = build_style_name( - width if width != 'Medium (normal)' else '', - weight if weight != 'Regular' else '', - custom, - is_italic - ) - master.name = ufo.info.styleName diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index ad933f209..3e809952a 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -515,3 +515,13 @@ def test_warn_diff_between_designspace_and_ufos(caplog): 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' From 1c19f25cf5c4dfdee61dad6e2f497a5b14a4cf15 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 8 Mar 2018 19:57:23 +0000 Subject: [PATCH 40/44] Fix customName-related mistakes * Remove customName{1,2,3} which don't exist * Handle customName when going UFO->Glyphs only using the setter --- Lib/glyphsLib/builder/custom_params.py | 2 -- Lib/glyphsLib/builder/masters.py | 5 ++--- Lib/glyphsLib/classes.py | 11 +---------- tests/builder/builder_test.py | 5 ++--- tests/writer_test.py | 10 ++-------- 5 files changed, 7 insertions(+), 26 deletions(-) diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index 8ffe425c3..d323e1fc5 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -445,8 +445,6 @@ def _write_to_glyphs(self, glyphs, value): )) for number in ('', '1', '2', '3'): - register(MiscParamHandler('customName' + number, ufo_info=False, - ufo_default='')) register(MiscParamHandler('customValue' + number, ufo_info=False)) register(MiscParamHandler('weightValue', ufo_info=False)) register(MiscParamHandler('widthValue', ufo_info=False)) diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index 2dd6a434a..76faa6f47 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -66,10 +66,9 @@ def to_ufo_master_attributes(self, source, master): ufo.lib[GLYPHS_PREFIX + 'width'] = width if widthValue: ufo.lib[GLYPHS_PREFIX + 'widthValue'] = widthValue + if master.customName: + ufo.lib[GLYPHS_PREFIX + 'customName'] = master.customName 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 diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 1a055fb54..cd2b4df51 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -1216,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, @@ -1261,9 +1258,6 @@ class GSFontMaster(GSBase): _wrapperKeysTranslate = { "guideLines": "guides", "custom": "customName", - "custom1": "customName1", - "custom2": "customName2", - "custom3": "customName3", } _keyOrder = ( "alignmentZones", @@ -1271,11 +1265,8 @@ class GSFontMaster(GSBase): "capHeight", "custom", "customValue", - "custom1", "customValue1", - "custom2", "customValue2", - "custom3", "customValue3", "customParameters", "descender", @@ -1303,8 +1294,8 @@ def __init__(self): self._customParameters = [] 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): diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index 70f148e60..24a6f31cd 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -775,14 +775,13 @@ def test_lib_width(self): def test_lib_no_custom(self): font = generate_minimal_font() ufo = to_ufos(font)[0] - self.assertFalse(MASTER_CUSTOM_PARAM_PREFIX + 'customName' in ufo.lib) + self.assertFalse(GLYPHS_PREFIX + 'customName' in ufo.lib) def test_lib_custom(self): font = generate_minimal_font() font.masters[0].customName = 'FooBar' ufo = to_ufos(font)[0] - self.assertEqual( - ufo.lib[MASTER_CUSTOM_PARAM_PREFIX + 'customName'], 'FooBar') + self.assertEqual(ufo.lib[GLYPHS_PREFIX + 'customName'], 'FooBar') def test_coerce_to_bool(self): font = generate_minimal_font() diff --git a/tests/writer_test.py b/tests/writer_test.py index aa41e157a..431aef095 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -269,14 +269,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 @@ -314,13 +311,10 @@ 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 = ( { From 5d61553c4d65b720328b1cf73250894b0b3b5575 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 14 Mar 2018 17:47:49 +0000 Subject: [PATCH 41/44] Fix roundtrip of custom master names and icon --- Lib/glyphsLib/builder/masters.py | 8 ------- Lib/glyphsLib/builder/names.py | 17 +++++++++++++- Lib/glyphsLib/classes.py | 40 +++++++++++++++++++++++++------- tests/builder/builder_test.py | 20 +++++++++++++++- tests/classes_test.py | 6 +++++ 5 files changed, 72 insertions(+), 19 deletions(-) diff --git a/Lib/glyphsLib/builder/masters.py b/Lib/glyphsLib/builder/masters.py index 76faa6f47..bd1d78fa6 100644 --- a/Lib/glyphsLib/builder/masters.py +++ b/Lib/glyphsLib/builder/masters.py @@ -54,20 +54,12 @@ def to_ufo_master_attributes(self, source, master): # "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? - width = master.width widthValue = master.widthValue - weight = master.weight weightValue = master.weightValue - if weight: - ufo.lib[GLYPHS_PREFIX + 'weight'] = weight if weightValue is not None: ufo.lib[GLYPHS_PREFIX + 'weightValue'] = weightValue - if width: - ufo.lib[GLYPHS_PREFIX + 'width'] = width if widthValue: ufo.lib[GLYPHS_PREFIX + 'widthValue'] = widthValue - if master.customName: - ufo.lib[GLYPHS_PREFIX + 'customName'] = master.customName for number in ('', '1', '2', '3'): custom_value = getattr(master, 'customValue' + number) if custom_value: diff --git a/Lib/glyphsLib/builder/names.py b/Lib/glyphsLib/builder/names.py index e368a6751..29ee33d65 100644 --- a/Lib/glyphsLib/builder/names.py +++ b/Lib/glyphsLib/builder/names.py @@ -17,11 +17,21 @@ 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 = master.name or build_style_name( @@ -115,4 +125,9 @@ def to_glyphs_family_names(self, ufo, merge=False): def to_glyphs_master_names(self, ufo, master): - master.name = ufo.info.styleName + 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/classes.py b/Lib/glyphsLib/classes.py index cd2b4df51..b7c92610f 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -1226,9 +1226,7 @@ class GSFontMaster(GSBase): "iconName": str, "id": str, "italicAngle": float, - "name": unicode, # FIXME: (jany) does not seem to be filled in by - # Glyphs 1113, instead chops up the name into - # weight or custom. + "name": unicode, "userData": dict, "verticalStems": int, "visible": bool, @@ -1323,19 +1321,43 @@ def name(self): return self._joinName() @name.setter - def name(self, value): - # This is what Glyphs 1113 seems to be doing, approximately. - self.weight, self.width, self.customName = self._splitName(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 + self.width = width + self.customName = custom_name # Only store the requested name if we can't build it from the parts - if self._joinName() == value: + if self._joinName() == name: self._name = None + # This setter is called during __init__ when customParameters are + # not ready yet. + try: + del self.customParameters['Master Name'] + except: + pass else: - self._name = value + self._name = name + try: + self.customParameters['Master Name'] = name + except: + pass def _joinName(self): names = [self.weight, self.width, self.customName] names = [n for n in names if n] # Remove None and empty string - if len(names) > 1 and "Regular" in names: + # 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") diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index 24a6f31cd..33bfc83ed 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -32,7 +32,7 @@ GSPath, GSNode, GSAnchor, GSComponent, GSAlignmentZone, GSGuideLine) from glyphsLib.types import Point -from glyphsLib.builder import to_ufos +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 @@ -917,6 +917,24 @@ def test_glyph_lib_component_alignment_and_locked_and_smart_values(self): 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' + class _PointDataPen(object): diff --git a/tests/classes_test.py b/tests/classes_test.py index e28e7b7f4..b749a82ec 100755 --- a/tests/classes_test.py +++ b/tests/classes_test.py @@ -575,6 +575,12 @@ def test_name(self): master.customName = '' 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 From fb5cbb902615637415b56d19f411dd89a2b5d43f Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Wed, 14 Mar 2018 18:51:01 +0000 Subject: [PATCH 42/44] Fix the fix of roundtrip of custom master name --- Lib/glyphsLib/classes.py | 23 ++++++++--------------- tests/builder/builder_test.py | 16 ++++++++++++++++ tests/writer_test.py | 9 ++++----- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index b7c92610f..08e1bf4f1 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -1256,6 +1256,7 @@ class GSFontMaster(GSBase): _wrapperKeysTranslate = { "guideLines": "guides", "custom": "customName", + "name": "_name", } _keyOrder = ( "alignmentZones", @@ -1306,9 +1307,9 @@ def shouldWriteValueForKey(self, key): if key in ("xHeight", "capHeight", "ascender", "descender"): # Always write those values return True - if key == "name": + if key == "_name": # Only write out the name if we can't make it by joining the parts - return self.name != self._joinName() + return self._name != self.name return super(GSFontMaster, self).shouldWriteValueForKey(key) @property @@ -1334,24 +1335,16 @@ def set_all_name_components(self, name, weight, width, custom_name): master.weight, master.width, and master.customName match the given values. """ - self.weight = weight - self.width = width - self.customName = custom_name + 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 - # This setter is called during __init__ when customParameters are - # not ready yet. - try: - del self.customParameters['Master Name'] - except: - pass + del self.customParameters['Master Name'] else: self._name = name - try: - self.customParameters['Master Name'] = name - except: - pass + self.customParameters['Master Name'] = name def _joinName(self): names = [self.weight, self.width, self.customName] diff --git a/tests/builder/builder_test.py b/tests/builder/builder_test.py index 33bfc83ed..b7c085570 100644 --- a/tests/builder/builder_test.py +++ b/tests/builder/builder_test.py @@ -23,6 +23,9 @@ import io import logging import unittest +import tempfile +import os +import shutil from defcon import Font from fontTools.misc.loggingTools import CapturingLogHandler @@ -935,6 +938,19 @@ def test_master_with_light_weight_but_thin_name(self): 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): diff --git a/tests/writer_test.py b/tests/writer_test.py index 431aef095..b97c0c66b 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -256,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 @@ -319,7 +318,7 @@ def test_write_font_master_attributes(self): customParameters = ( { name = "Master Name"; - value = "Hairline Megawide"; + value = "Param Hairline Megawide"; }, { name = underlinePosition; @@ -339,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; }; From c729de4447ad2bb4eafae6c56b885cdbafa7e163 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Mon, 19 Mar 2018 12:09:15 +0000 Subject: [PATCH 43/44] Update comments --- Lib/glyphsLib/builder/__init__.py | 23 ++++++--- Lib/glyphsLib/builder/axes.py | 11 ++-- Lib/glyphsLib/builder/builders.py | 69 +++++++------------------- Lib/glyphsLib/builder/custom_params.py | 41 ++++++++++++--- Lib/glyphsLib/builder/features.py | 15 +++--- Lib/glyphsLib/builder/font.py | 5 -- Lib/glyphsLib/builder/glyph.py | 27 +++++----- Lib/glyphsLib/builder/groups.py | 2 +- Lib/glyphsLib/builder/instances.py | 43 +++++----------- Lib/glyphsLib/builder/layers.py | 40 ++++++++++++++- Lib/glyphsLib/classes.py | 4 +- tests/builder/custom_params_test.py | 6 +-- tests/builder/features_test.py | 4 +- tests/builder/to_glyphs_test.py | 6 +-- tests/writer_test.py | 2 - 15 files changed, 155 insertions(+), 143 deletions(-) diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py index 498614205..ff863fb4f 100644 --- a/Lib/glyphsLib/builder/__init__.py +++ b/Lib/glyphsLib/builder/__init__.py @@ -30,8 +30,7 @@ def to_ufos(font, propagate_anchors=True, ufo_module=defcon, minimize_glyphs_diffs=False): - # TODO: (jany) Update documentation - """Take .glyphs file data and load it into UFOs. + """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/ @@ -61,8 +60,16 @@ def to_designspace(font, propagate_anchors=True, ufo_module=defcon, minimize_glyphs_diffs=False): - # TODO: (jany) Update documentation - """Take .glyphs file data and load it into a Designspace Document + UFOS. + """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/ @@ -87,9 +94,13 @@ def to_glyphs(ufos_or_designspace, glyphs_module=classes, minimize_ufo_diffs=False): """ - Take a list of UFOs and combine them into a single .glyphs file. + 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 """ diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py index c3bed9aec..e50fe183e 100644 --- a/Lib/glyphsLib/builder/axes.py +++ b/Lib/glyphsLib/builder/axes.py @@ -212,7 +212,7 @@ def to_glyphs_axes(self): axes_parameter.append({'Name': width.name or 'Width', 'Tag': 'wdth'}) # TODO: (jany) store other data about this axis? elif customs: - # Add a dumb weight axis to not mess up the indices + # 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 @@ -279,13 +279,14 @@ def get_user_loc(self, instance): 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... FIXME: clarify what it is for the width. + 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': - # FIXME: (jany) existing test "DesignspaceTestTwoAxes.designspace" - # suggests that the user location is the same as the design loc - # for the width only + # 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 diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py index 0970b7d86..716c1aa25 100644 --- a/Lib/glyphsLib/builder/builders.py +++ b/Lib/glyphsLib/builder/builders.py @@ -139,59 +139,38 @@ def masters(self): self.to_ufo_font_attributes(self.family_name) for glyph in self.font.glyphs: - glyph_name = 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._sources[layer_id].font - 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) - ufo_layer = ufo.layers.defaultLayer - if self.minimize_glyphs_diffs: - ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + - glyph.name] = self._layer_order_in_glyph( - layer) - - for master_id, glyph_name, layer_name, layer \ - in supplementary_layer_data: - if (layer.layerId not in master_layer_ids - and layer.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.layerId)) + 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._sources[master_id].font - if layer_name not in ufo_font.layers: - ufo_layer = ufo_font.newLayer(layer_name) - else: - ufo_layer = ufo_font.layers[layer_name] - # TODO: (jany) move as much as possible into layers.py - if self.minimize_glyphs_diffs: - ufo_layer.lib[GLYPHS_PREFIX + 'layerId'] = layer.layerId - ufo_layer.lib[GLYPHS_PREFIX + 'layerOrderInGlyph.' + - glyph_name] = self._layer_order_in_glyph(layer) - ufo_glyph = ufo_layer.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, layer.parent) for source in self._sources.values(): @@ -208,20 +187,6 @@ def masters(self): for source in self._sources.values(): yield source.font - def _layer_order_in_glyph(self, layer): - # TODO: move to layers.py - # TODO: optimize? - for order, glyph_layer in enumerate(layer.parent.layers.values()): - if glyph_layer is layer: - return order - return None - - @property - def instances(self): - """Get an iterator over interpolated UFOs of instances.""" - # TODO? - return [] - @property def designspace(self): """Get a designspace Document instance that links the masters together @@ -231,7 +196,6 @@ def designspace(self): return self._designspace self._designspace_is_complete = True ufos = list(self.masters) # Make sure that the UFOs are built - # FIXME: (jany) feels wrong self.to_designspace_axes() self.to_designspace_sources() self.to_designspace_instances() @@ -284,6 +248,7 @@ def instance_data(self): 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_paths @@ -319,7 +284,7 @@ def __init__(self, the designspace's sources. Instance and axis data will be converted to Glyphs. * Both a designspace and some UFOs: not supported for now. - TODO: find out whether there is a use-case here? + TODO: (jany) find out whether there is a use-case here? Keyword arguments: ufos -- The list of UFOs to combine into a GSFont @@ -405,7 +370,7 @@ 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: really make a copy to avoid modifying the original object + # 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: diff --git a/Lib/glyphsLib/builder/custom_params.py b/Lib/glyphsLib/builder/custom_params.py index d323e1fc5..17f5b5c6a 100644 --- a/Lib/glyphsLib/builder/custom_params.py +++ b/Lib/glyphsLib/builder/custom_params.py @@ -25,9 +25,28 @@ CODEPAGE_RANGES, REVERSE_CODEPAGE_RANGES) from .features import replace_feature -# TODO: update this documentation """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 @@ -286,7 +305,7 @@ def register(handler): 'openTypeNameVersion', 'openTypeNameUniqueID', - # TODO: look at https://forum.glyphsapp.com/t/name-table-entry-win-id4/3811/10 + # 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', @@ -330,7 +349,7 @@ def register(handler): register(ParamHandler(name)) -# TODO: handle dynamic version number replacement +# TODO: (jany) handle dynamic version number replacement register(ParamHandler('versionString', 'openTypeNameVersion')) @@ -352,7 +371,7 @@ def to_glyphs(self, glyphs, ufo): glyphs_name='codePageRanges', ufo_name='openTypeOS2CodePageRanges', value_to_ufo=lambda value: [CODEPAGE_RANGES[v] for v in value], - # TODO: handle KeyError, store into userData + # 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 @@ -362,7 +381,7 @@ def to_glyphs(self, glyphs, ufo): # 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: only write if contains non-codepage values + value_to_glyphs=lambda value: value # TODO: (jany) only write if contains non-codepage values # TODO: (jany) add test with non-codepage values )) @@ -528,7 +547,12 @@ def to_ufo(self, glyphs, ufo): tag, repl, ufo._owner.features.text or "") def to_glyphs(self, glyphs, ufo): - # TODO + # 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 register(ReplaceFeatureParamHandler()) @@ -558,8 +582,9 @@ def to_glyphs_custom_params(self, ufo, glyphs_object): for handler in KNOWN_PARAM_HANDLERS: handler.to_glyphs(glyphs_proxy, ufo_proxy) - # TODO: (jany) Make sure that all parameters of the UFO info have a handler - # That way, only lib can have extra stuff + # 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) diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py index c65055139..bc0f8c79d 100644 --- a/Lib/glyphsLib/builder/features.py +++ b/Lib/glyphsLib/builder/features.py @@ -186,9 +186,9 @@ def to_glyphs_features(self): # Handle differing feature files between input UFOs # For now: switch to very simple strategy if there is any difference - # TODO: 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. + # 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( @@ -211,8 +211,8 @@ def to_glyphs_features(self): def _features_are_different_across_ufos(self): - # FIXME: requires that features are in the same order in all feature files - # only allowed deviation is whitespace + # 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:]: @@ -393,8 +393,6 @@ def _process_file(self): unhandled_root_elements.clear() else: # FIXME: (jany) Maybe print warning about unhandled fea block? - # TODO: (jany) Check the list of all possible blocks in ast and - # handle them all (even if dummy implem) unhandled_root_elements.append(self.statements.peek()) self.statements.next() # Flush any unhandled root elements into an anonymous prefix @@ -522,6 +520,9 @@ def _process_gdef_table_block(self): 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): diff --git a/Lib/glyphsLib/builder/font.py b/Lib/glyphsLib/builder/font.py index febb52e04..6d021baa7 100644 --- a/Lib/glyphsLib/builder/font.py +++ b/Lib/glyphsLib/builder/font.py @@ -101,11 +101,6 @@ def to_glyphs_font_attributes(self, source, master, is_initial): master -- The current master being written is_initial -- True iff this the first UFO that we process """ - # TODO: (jany) when is_initial, write to context.font without question - # but when !is_initial, compare the last context.font.whatever and - # what we would be writing, to guard against the info being - # modified in only one of the UFOs in a MM. Maybe do this check later, - # when the roundtrip without modification works. if is_initial: _set_glyphs_font_attributes(self, source) else: diff --git a/Lib/glyphsLib/builder/glyph.py b/Lib/glyphsLib/builder/glyph.py index d60607643..7e1991197 100644 --- a/Lib/glyphsLib/builder/glyph.py +++ b/Lib/glyphsLib/builder/glyph.py @@ -97,7 +97,7 @@ def to_ufo_glyph(self, ufo_glyph, layer, glyph): 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 + # 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 @@ -165,11 +165,18 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): layer = self.to_glyphs_layer(ufo_layer, glyph, master) for key in ['leftMetricsKey', 'rightMetricsKey', 'widthMetricsKey']: - for prefix, object in (('glyph.', glyph), ('layer.', layer)): + # 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(object, key, value) + setattr(glyphs_object, key, value) if SCRIPT_LIB_KEY in ufo_glyph.lib: glyph.script = ufo_glyph.lib[SCRIPT_LIB_KEY] @@ -199,7 +206,7 @@ def to_glyphs_glyph(self, ufo_glyph, ufo_layer, master): # Restore originalWidth if ORIGINAL_WIDTH_KEY in ufo_glyph.lib: layer.width = ufo_glyph.lib[ORIGINAL_WIDTH_KEY] - # TODO: check for customParameter DisableAllAutomaticBehaviour? + # TODO: (jany) check for customParam DisableAllAutomaticBehaviour? self.to_glyphs_background_image(ufo_glyph, layer) self.to_glyphs_guidelines(ufo_glyph, layer) @@ -221,17 +228,7 @@ def to_ufo_glyph_background(self, glyph, layer): return background = layer.background - - # FIXME: (jany) move most of this to layers.py - if glyph.layer.name != 'public.default': - layer_name = glyph.layer.name + '.background' - else: - layer_name = 'public.background' - font = glyph.font - if layer_name not in font.layers: - ufo_layer = font.newLayer(layer_name) - else: - ufo_layer = font.layers[layer_name] + ufo_layer = self.to_ufo_background_layer(glyph) new_glyph = ufo_layer.newGlyph(glyph.name) width = background.userData[BACKGROUND_WIDTH_KEY] diff --git a/Lib/glyphsLib/builder/groups.py b/Lib/glyphsLib/builder/groups.py index 86d6e538a..6ae43b011 100644 --- a/Lib/glyphsLib/builder/groups.py +++ b/Lib/glyphsLib/builder/groups.py @@ -152,7 +152,7 @@ def _glyph_kerning_attr(glyph, side): def _is_ltr(glyph): - # TODO: (jany) have a real implem? + # 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 diff --git a/Lib/glyphsLib/builder/instances.py b/Lib/glyphsLib/builder/instances.py index 1bcb30b00..af7084f69 100644 --- a/Lib/glyphsLib/builder/instances.py +++ b/Lib/glyphsLib/builder/instances.py @@ -67,8 +67,10 @@ def _to_designspace_instance(self, instance): ufo_instance.familyName = self.family_name ufo_instance.styleName = instance.name - # TODO: investigate the possibility of storing a relative path in the - # `filename` custom parameter. If yes, drop the key below. + # 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: @@ -149,29 +151,6 @@ def to_glyphs_instances(self): for ufo_instance in self.designspace.instances: instance = self.glyphs_module.GSInstance() - # TODO: lots of stuff! - # active - # name - # weight - # width - # weightValue - # widthValue - # customValue - # isItalic - # isBold - # linkStyle - # familyName - # preferredFamily - # preferredSubfamilyName - # windowsFamily - # windowsStyle - # windowsLinkedToStyle - # fontName - # fullName - # customParameters - # instanceInterpolations - # manualInterpolation - try: instance.active = ufo_instance.lib[EXPORT_KEY] except KeyError: @@ -191,11 +170,7 @@ def to_glyphs_instances(self): if axis_def.tag in ('wght', 'wdth'): # Retrieve the user location (weightClass/widthClass) - # TODO: (jany) update comments - # First way: for UFOs/designspace of other origins, read - # the mapping backwards and check that the user location - # matches the instance's weight/width. If not, set the the - # custom param. + # Generic way: read the axis mapping backwards. user_loc = design_loc mapping = None for axis in self.designspace.axes: @@ -208,10 +183,16 @@ def to_glyphs_instances(self): axis_def.set_user_loc(instance, user_loc) try: - # Restore the original weightClass when there is an ambiguity based + # 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 diff --git a/Lib/glyphsLib/builder/layers.py b/Lib/glyphsLib/builder/layers.py index 3a8e75720..38cf75605 100644 --- a/Lib/glyphsLib/builder/layers.py +++ b/Lib/glyphsLib/builder/layers.py @@ -19,9 +19,47 @@ 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) @@ -54,7 +92,7 @@ def to_glyphs_layer(self, ufo_layer, glyph, master): layer.layerId = ufo_layer.lib[LAYER_ID_KEY] layer.name = ufo_layer.name glyph.layers.append(layer) - order_key = GLYPHS_PREFIX + 'layerOrderInGlyph.' + glyph.name + 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 diff --git a/Lib/glyphsLib/classes.py b/Lib/glyphsLib/classes.py index 08e1bf4f1..b9f649602 100755 --- a/Lib/glyphsLib/classes.py +++ b/Lib/glyphsLib/classes.py @@ -3139,11 +3139,11 @@ def gridLength(self): def kerningForPair(self, fontMasterId, leftKey, rightKey, direction=LTR): # TODO: (jany) understand and use the direction parameter if not self._kerning: - return EMPTY_KERNING_VALUE + return self.EMPTY_KERNING_VALUE try: return self._kerning[fontMasterId][leftKey][rightKey] except KeyError: - return EMPTY_KERNING_VALUE + return self.EMPTY_KERNING_VALUE def setKerningForPair(self, fontMasterId, leftKey, rightKey, value, direction=LTR): diff --git a/tests/builder/custom_params_test.py b/tests/builder/custom_params_test.py index 0203a1eda..e5105c53f 100644 --- a/tests/builder/custom_params_test.py +++ b/tests/builder/custom_params_test.py @@ -187,7 +187,7 @@ def test_xHeight(self): '500') # The xHeight from the property is not modified self.assertEqual(self.ufo.info.xHeight, 300) - # TODO: check that the instance custom param wins over the + # TODO: (jany) check that the instance custom param wins over the # interpolated value def test_replace_feature(self): @@ -261,8 +261,8 @@ def test_empty_fstype(self): self.assertEqual(self.ufo.info.openTypeOS2Type, []) def test_version_string(self): - # TODO: test the automatic replacement that is described in the Glyphs - # Handbook + # 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/features_test.py b/tests/builder/features_test.py index 65e5e7256..1b00b7ca4 100644 --- a/tests/builder/features_test.py +++ b/tests/builder/features_test.py @@ -236,8 +236,8 @@ def test_different_features_in_different_UFOS(tmpdir): # If the input UFOs have different features, Glyphs cannot model the # differences easily. # - # TODO: 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 + # 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. # diff --git a/tests/builder/to_glyphs_test.py b/tests/builder/to_glyphs_test.py index 3e809952a..e33d0dda7 100644 --- a/tests/builder/to_glyphs_test.py +++ b/tests/builder/to_glyphs_test.py @@ -144,9 +144,9 @@ def test_groups(): ufo.groups['public.kern2.hebrewLikeO'] = ['samekh-hb'] groups_dict = dict(ufo.groups) - # TODO: add a test with 2 UFOs with conflicting data - # TODO: add a test with with both UFO groups and feature file classes - # TODO: add a test with UFO groups that conflict with feature file classes + # 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 diff --git a/tests/writer_test.py b/tests/writer_test.py index b97c0c66b..6dfcf1d49 100644 --- a/tests/writer_test.py +++ b/tests/writer_test.py @@ -370,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)" @@ -1045,7 +1044,6 @@ 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 From cf79551c81d065cbb270fa1e0b20a1cfbbcb334f Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Mon, 19 Mar 2018 17:20:22 +0000 Subject: [PATCH 44/44] Parse unicode lists as written out by Glyphs 2.4.4 --- Lib/glyphsLib/parser.py | 24 +++++++++++++++++++----- tests/parser_test.py | 6 ++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Lib/glyphsLib/parser.py b/Lib/glyphsLib/parser.py index 3a2863f75..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 @@ -33,6 +32,7 @@ class Parser(object): """Parses Python dictionaries from Glyphs source files.""" value_re = r'(".*?(?