Repository: fonttools/fonttools

Issue:
Tool to split/merge TTC
As requested by Adam.  Would be useful.  Possibly as a separate tool as well as with support in ttx.



Discussion:
The FDK now includes `otc2otf` and `otf2otc` tools.

@twardoch ^^^

Cool :) I'm leaving this one open so Behdad remembers that he intends to still improve fontTools a bit (ability to handle TTC as a whole, he mentioned). 

@miguelsousa Is there documentation about those tools in FDK?  I can provide the same API.

@behdad the documentation that exists is in the scripts themselves and can be obtained by using the `-h` option.

```
otc2otf otc2otf.py  [-r] <font.ttc>

Extract all OpenType fonts from the parent OpenType Collection font.
-r  Optional. Report TTC fonts and tables, Will not write the output files.

example:
 AFDKOPython otc2otf.py  -r  LogoCutStd.ttc

The script may be invoked with either the FDK command:
  otc2otf
or directly with the command:
  python <path to FDK directory>/FDK/Tools/SharedData/FDKScripts/otc2otf.py
```

```
otf2otc  -t <table tag=src font index> -o <output ttc file name> <input font file 0> ... <input font file n>

-t <table tag=source font index>  Optional. When this option is present, the matching table from the specified font file is
used for all the font files.

example:
  otf2otc -t 'CFF '=2 -o LogoCutStd.ttc LogoCutStd-Light.otf LogoCutStd-Medium.otf LogoCutStd-Bold.otf LogoCutStd-Ultra.otf
# The 'ttc' file will contain only one CFF table, taken from  LogoCutStd-Bold.otf.

The script may be invoked with either the FDK command:
  otf2otc
or directly with the command:
  python <path to FDK directory>/FDK/Tools/SharedData/FDKScripts/otf2otc.py

Build an OpenType Collection font file from two or more OpenType font
files. The fonts are ordered in the output 'ttc' file in the same order
that the file names are listed in the command line. If a table is
identical for more than one font file, it is shared.

For each file, check that it looks like an sfnt file.

For each file, read in tables. Build a font object list, and dict of table tags to table data blocks.

A font object contains;
  font file name
  list of [tableTag, table data, shared] pairs

as the font tables are being read in, they are compared against the list of already seen data items.
If any match, the font table reference is replaced by the reference to the table already seen.
```

I'd like to add support for collections to the WOFF2 encoder. However we first need to decide on an API for `TTCollection`.
Any suggestions?

Let me upload whatever I have and leave it to your mighty hands...

:+1: 

BTW, note that the otc tools in FDK are completely standalone, simple, dependency-free pure-Python tools: 
https://github.com/adobe-type-tools/afdko/blob/master/FDK/Tools/SharedData/FDKScripts/otc2otf.py
https://github.com/adobe-type-tools/afdko/blob/master/FDK/Tools/SharedData/FDKScripts/otf2otc.py

I think this could be easily lifted, except that FDK is Apache 2 while fontTools is MIT-licensed. Not sure how this affects things. 

A.

Note that Doug also wrote one in nototools:
https://github.com/googlei18n/nototools/blob/master/nototools/ttc_utils.py
https://github.com/googlei18n/nototools/blob/master/nototools/decompose_ttc.py

I agree we want to add API though.  Unfortunately I cannot find what I had :(.  It wasn't much though, was TTCollection indeed.  For decompiling, it's easy.  I think we want to set something like collection.fonts to be a list of TTFont() objects.  Add iteration to the collection object as well such that we can just do for "font in collection:".

Compiling becomes more complicated, but I leave that to you!

Also, maybe we should add a TTFontOrCollection() function that creates either a TTFont or TTCollection based on the font data.  We also need to add a simpler way to detect what the data represents.  Currently one has to try to open...

TTCollection should preferably have much of the same API as TTFont, in terms of compile(), save(), fromXML(), etc.

Thanks!

FWIW I did a test implementation (not realizing this issue existed) of TTC for fontTools and I found needing to branch what I was creating and worry about whether I had a TTFont or a SomethingElse annoying. I found it more convenient to make TTFont capable of representing either a regular font or a collection. If its a collection it has a fonts attribute with the TTFont's within the collection. This results in compile, save, to/from XML working with minimal changes.

> I found it more convenient to make TTFont capable of representing either a regular font or a collection. If its a collection it has a fonts attribute with the TTFont's within the collection. This results in compile, save, to/from XML working with minimal changes.

Since it's Python, you can have two different types that implement the same save, XML, compile, ... API...

The convenience factor was mostly avoiding having to open the file to decide which object I was creating. As far as the API, my thinking was just that if it's the same entry points it doesn't have "much of the same API", it has exactly the same API and there is less tendency for those APIs to diverge over time. Either way is certainly workable.

However, IMHO the more interesting question is how to handle editing shared tables. For example, if fonts 0 and 1 share glyf, can I reach in and edit by both `fonts[0]['glyf']` and `fonts[1]['glyf']`? How does one check if a table is shared or cause it to become shared? 

@anthrotype Want to take this one?  Perhaps summarize what we discussed in person?  Thanks.

Sure, I was planning to do it as soon as I finish the logging thing I'm working on.

While implementing DSIG checking a while ago, I noticed some TTCs have individual DSIG's in their member fonts, and I have always wondered if I can split them up and verify the individual ones.  MS Font Validator has some code for writing fonts (which itself does not use), so this is probably do'able.

@rsheeter : about sharing tables. Sharing glyf table is messy (or have a superset that includes all the edits), but sharing most other tables could work at a copy-on-write manner, I guess; and just before writing, one needs to calculate the table checksum anyway, and I seem to have seen some code, either in ttx or in FontVal, which avoid writing duplicate tables of identical checksums.

Hmm, it is in FontVal, OTFontFile/OTFile.cs:WriteTTCFile(), line 566 onwards - it does a barbaric binary check.

@behdad @davelab6 @anthrotype @aaronbell @twardoch @miguelsousa @n8willis : A mere 30 lines (include blank lines, minus comments), IronPython Fontval-based example to split ttc's.

https://gist.github.com/HinTak/33cdcb8e1558538389cb5e0475674acc

I have only tried it on mingliu.tttc and the output seems to be valid. This is just to check that the Writefont functionality inside FontVal (which it does not use itself - obviously it has no use of that sort of routines) is functional. I might update it a bit to do what I have in mind - see if after splitting, whether individual DSIG in member fonts are valid in some such files I see.

You shouldn't bother verifying the individual DSIGs of TTC's members. They should have never been there in the first place. TTC may only have a single Format-1 DSIG table for the whole collection.

From https://www.microsoft.com/en-us/Typography/dsig.aspx

> Individual fonts included in a TrueType collection should not be individually signed as the process of making the TTC could invalidate the signature on the font.

> You shouldn't bother verifying the individual DSIGs of TTC's members.

I know - I am just curious whether the merge happens immediately after the signing, or if there are changes between signing and merging.

The corresponding FontVal-based IronPython script to merge truetype fonts into a truetype collection is about the same length, and 2/3 the same - they use the same modules, etc, obviously. 

https://gist.github.com/HinTak/96a686572a824d78f8e1967b464821aa

Ran the splitter and merger against mingliu, @rsheeter , and there was already de-duplication on write, which is, I am glad to see, functional - note re-merge is a lot smaller than sum of all members:

32217124 original
27349496 member0
27349516 member1
27387460 member2
32210100 re-merged version

The 7k difference between the original and the new-merge is the TTC DSIG. The old version had all the directories up front, while the new has 2nd directory after tables of 1st font, etc.

The 32MB TTC is about 22MB of glyphs, common to all three members, but contains two versions of EBDT at 5MB . So that's how all 3 member fonts are about 27MB each.


Resolving patch:
diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py
new file mode 100644
--- /dev/null
+++ b/Lib/fontTools/designspaceLib/__init__.py
@@ -0,0 +1,1351 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import print_function, division, absolute_import
+import collections
+import logging
+import os
+import posixpath
+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 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
+
+
+def posixpath_property(private_name):
+    def getter(self):
+        # Normal getter
+        return getattr(self, private_name)
+
+    def setter(self, value):
+        # The setter rewrites paths using forward slashes
+        if value is not None:
+            value = posix(value)
+        setattr(self, private_name, value)
+
+    return property(getter, setter)
+
+
+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
+
+    path = posixpath_property("_path")
+    filename = posixpath_property("_filename")
+
+
+class RuleDescriptor(SimpleDescriptor):
+    """<!-- optional: list of substitution rules -->
+    <rules>
+        <rule name="vertical.bars" enabled="true">
+            <sub name="cent" byname="cent.alt"/>
+            <sub name="dollar" byname="dollar.alt"/>
+            <condition tag="wght" minimum ="250.000000" maximum ="750.000000"/>
+            <condition tag="wdth" minimum ="100"/>
+            <condition tag="opsz" minimum="10" maximum="40"/>
+        </rule>
+    </rules>
+
+    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']
+
+    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
+
+    path = posixpath_property("_path")
+    filename = posixpath_property("_filename")
+
+    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):
+        return cls.axisDescriptorClass()
+
+    @classmethod
+    def getSourceDescriptor(cls):
+        return cls.sourceDescriptorClass()
+
+    @classmethod
+    def getInstanceDescriptor(cls):
+        return cls.instanceDescriptorClass()
+
+    @classmethod
+    def getRuleDescriptor(cls):
+        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 = list(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 = list(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 = list(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 = list(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)
+        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):
+    ruleDescriptorClass = RuleDescriptor
+    axisDescriptorClass = AxisDescriptor
+    sourceDescriptorClass = SourceDescriptor
+    instanceDescriptorClass = InstanceDescriptor
+
+    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.ruleDescriptorClass()
+            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.axisDescriptorClass()
+            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.axisDescriptorClass()
+            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.sourceDescriptorClass()
+            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.instanceDescriptorClass()
+        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)
+        self.documentObject.instances.append(instanceObject)
+
+    def readInfoElement(self, infoElement, instanceObject):
+        """ Read the info element.
+
+            ::
+
+                <info/>
+
+                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.
+                <kerning/>
+
+        """
+        kerningLocation = self.locationFromElement(kerningElement)
+        instanceObject.addKerning(kerningLocation)
+
+    def readGlyphElement(self, glyphElement, instanceObject):
+        """
+        Read the glyph element.
+
+        ::
+
+            <glyph name="b" unicode="0x62"/>
+
+            <glyph name="b"/>
+
+            <glyph name="b">
+                <master location="location-token-bbb" source="master-token-aaa2"/>
+                <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/>
+
+                <note>
+                    This is an instance from an anisotropic interpolation.
+                </note>
+            </glyph>
+
+        """
+        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):
+        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
+
+    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 posix(relative)
+
+    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 newAxisDescriptor(self):
+        # Ask the writer class to make us a new axisDescriptor
+        return self.writerClass.getAxisDecriptor()
+
+    def newSourceDescriptor(self):
+        # Ask the writer class to make us a new sourceDescriptor
+        return self.writerClass.getSourceDescriptor()
+
+    def newInstanceDescriptor(self):
+        # Ask the writer class to make us a new instanceDescriptor
+        return self.writerClass.getInstanceDescriptor()
+
+    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
+        mutatorDefaultCandidate = self.getMutatorDefaultCandidate()
+        # 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 getMutatorDefaultCandidate(self):
+        # FIXME: original implementation using MutatorMath
+        # masterLocations = [src.location for src in self.sources]
+        # mutatorBias = biasFromLocations(masterLocations, preferOrigin=False)
+        # for src in self.sources:
+        #     if src.location == mutatorBias:
+        #         return src
+        return None
+
+    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
+
+
+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)


Test patch:
diff --git a/Tests/designspaceLib/designspace_test.py b/Tests/designspaceLib/designspace_test.py
new file mode 100644
--- /dev/null
+++ b/Tests/designspaceLib/designspace_test.py
@@ -0,0 +1,865 @@
+# coding=utf-8
+
+from __future__ import (print_function, division, absolute_import,
+                        unicode_literals)
+
+import os
+import pytest
+
+from fontTools.misc.py23 import open
+from fontTools.designspaceLib import (
+    DesignSpaceDocument, SourceDescriptor, AxisDescriptor, RuleDescriptor,
+    InstanceDescriptor, evaluateRule, processRules, posix)
+
+
+def test_fill_document(tmpdir):
+    tmpdir = str(tmpdir)
+    testDocPath = os.path.join(tmpdir, "test.designspace")
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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()
+
+    # Here, since the axes are not defined in the document, but instead are
+    # infered from the locations of the instances, we cannot guarantee the
+    # order in which they will be created by the `check()` method.
+    assert set(doc.getAxisOrder()) == set(['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()
+    assert 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 axis.tag not in axes:
+            axes[axis.tag] = []
+        axes[axis.tag].append(axis.serialize())
+    for axis in new.axes:
+        if axis.tag[0] == "_":
+            continue
+        if axis.tag not in axes:
+            axes[axis.tag] = []
+        axes[axis.tag].append(axis.serialize())
+    for v in axes.values():
+        a, b = v
+        assert a == b
+
+
+def test_adjustAxisDefaultToNeutral(tmpdir):
+    tmpdir = str(tmpdir)
+    testDocPath = os.path.join(tmpdir, "testAdjustAxisDefaultToNeutral.designspace")
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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 test_unicodes(tmpdir):
+    tmpdir = str(tmpdir)
+    testDocPath = os.path.join(tmpdir, "testUnicodes.designspace")
+    testDocPath2 = os.path.join(tmpdir, "testUnicodes_roundtrip.designspace")
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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', encoding='utf-8')
+    t1 = f1.read()
+    f1.close()
+    f2 = open(testDocPath2, 'r', encoding='utf-8')
+    t2 = f2.read()
+    f2.close()
+    assert t1 == t2
+    # check the unicode values read from the document
+    assert new.instances[0].glyphs['arrow']['unicodes'] == [100,200,300]
+
+
+def test_localisedNames(tmpdir):
+    tmpdir = str(tmpdir)
+    testDocPath = os.path.join(tmpdir, "testLocalisedNames.designspace")
+    testDocPath2 = os.path.join(tmpdir, "testLocalisedNames_roundtrip.designspace")
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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', encoding='utf-8')
+    t1 = f1.read()
+    f1.close()
+    f2 = open(testDocPath2, 'r', encoding='utf-8')
+    t2 = f2.read()
+    f2.close()
+    assert t1 == t2
+
+
+def test_handleNoAxes(tmpdir):
+    tmpdir = str(tmpdir)
+    # test what happens if the designspacedocument has no axes element.
+    testDocPath = os.path.join(tmpdir, "testNoAxes_source.designspace")
+    testDocPath2 = os.path.join(tmpdir, "testNoAxes_recontructed.designspace")
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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 test_pathNameResolve(tmpdir):
+    tmpdir = str(tmpdir)
+    # test how descriptor.path and descriptor.filename are resolved
+    testDocPath1 = os.path.join(tmpdir, "testPathName_case1.designspace")
+    testDocPath2 = os.path.join(tmpdir, "testPathName_case2.designspace")
+    testDocPath3 = os.path.join(tmpdir, "testPathName_case3.designspace")
+    testDocPath4 = os.path.join(tmpdir, "testPathName_case4.designspace")
+    testDocPath5 = os.path.join(tmpdir, "testPathName_case5.designspace")
+    testDocPath6 = os.path.join(tmpdir, "testPathName_case6.designspace")
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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 == posix(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 == posix(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 test_normalise():
+    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)
+
+    assert doc.normalizeLocation(dict(aaa=0)) == {'aaa': 0.0}
+    assert doc.normalizeLocation(dict(aaa=1000)) == {'aaa': 1.0}
+
+    # clipping beyond max values:
+    assert doc.normalizeLocation(dict(aaa=1001)) == {'aaa': 1.0}
+    assert doc.normalizeLocation(dict(aaa=500)) == {'aaa': 0.5}
+    assert doc.normalizeLocation(dict(aaa=-1000)) == {'aaa': -1.0}
+    assert doc.normalizeLocation(dict(aaa=-1001)) == {'aaa': -1.0}
+    # anisotropic coordinates normalise to isotropic
+    assert 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()
+    assert 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)
+    assert doc.normalizeLocation(dict(bbb=0)) == {'bbb': 0.0}
+    assert doc.normalizeLocation(dict(bbb=1000)) == {'bbb': 1.0}
+    # clipping beyond max values:
+    assert doc.normalizeLocation(dict(bbb=1001)) == {'bbb': 1.0}
+    assert doc.normalizeLocation(dict(bbb=500)) == {'bbb': 0.4444444444444444}
+    assert doc.normalizeLocation(dict(bbb=-1000)) == {'bbb': 0.0}
+    assert doc.normalizeLocation(dict(bbb=-1001)) == {'bbb': 0.0}
+    # anisotropic coordinates normalise to isotropic
+    assert doc.normalizeLocation(dict(bbb=(1000,-1000))) == {'bbb': 1.0}
+    assert 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()
+    assert 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)
+    assert doc.normalizeLocation(dict(ccc=0)) == {'ccc': 0.0}
+    assert doc.normalizeLocation(dict(ccc=1)) == {'ccc': 0.0}
+    assert doc.normalizeLocation(dict(ccc=-1000)) == {'ccc': -1.0}
+    assert 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()
+    assert 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)
+    assert doc.normalizeLocation(dict(ccc=0)) == {'ccc': 0.0}
+    assert doc.normalizeLocation(dict(ccc=1)) == {'ccc': 0.0}
+    assert doc.normalizeLocation(dict(ccc=-1000)) == {'ccc': 0.0}
+    assert 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()
+    assert 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()
+    assert r == [('ddd', [(0, 0.1), (300, 0.5), (600, 0.5), (1000, 0.9)])]
+
+
+def test_rules(tmpdir):
+    tmpdir = str(tmpdir)
+    testDocPath = os.path.join(tmpdir, "testRules.designspace")
+    testDocPath2 = os.path.join(tmpdir, "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
+    assert evaluateRule(r1, dict(aaaa = 500, bbbb = 0)) == True
+    assert evaluateRule(r1, dict(aaaa = 0, bbbb = 0)) == True
+    assert evaluateRule(r1, dict(aaaa = 1000, bbbb = 0)) == True
+    assert evaluateRule(r1, dict(aaaa = 1000, bbbb = -100)) == False
+    assert evaluateRule(r1, dict(aaaa = 1000.0001, bbbb = 0)) == False
+    assert evaluateRule(r1, dict(aaaa = -0.0001, bbbb = 0)) == False
+    assert evaluateRule(r1, dict(aaaa = -100, bbbb = 0)) == False
+    assert processRules([r1], dict(aaaa = 500), ["a", "b", "c"]) == ['a.alt', 'b', 'c']
+    assert processRules([r1], dict(aaaa = 500), ["a.alt", "b", "c"]) == ['a.alt', 'b', 'c']
+    assert 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"))
+
+    assert evaluateRule(r2, dict(aaaa = 0)) == True
+    assert evaluateRule(r2, dict(aaaa = -500)) == True
+    assert 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"))
+
+    assert evaluateRule(r3, dict(aaaa = 0)) == False
+    assert evaluateRule(r3, dict(aaaa = 1000)) == True
+    assert 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"))
+
+    assert evaluateRule(r4, dict()) == True  # is this what we expect though?
+    assert evaluateRule(r4, dict(aaaa = 1000, bbbb = 0)) == True
+    assert evaluateRule(r4, dict(aaaa = 0, bbbb = 0)) == False
+    assert 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)
+    assert 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'}}
+
+    assert doc.rules[0].conditions == [{'minimum': 0, 'maximum': 1000, 'name': 'aaaa'}, {'minimum': 0, 'maximum': 3000, 'name': 'bbbb'}]
+
+    assert doc.rules[0].subs == [('a', 'a.alt')]
+
+    doc.normalize()
+    assert doc.rules[0].name == 'named.rule.1'
+    assert 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)
+    assert len(new.axes) == 4
+    assert len(new.rules) == 1
+    new.write(testDocPath2)
+
+
+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', encoding='utf-8')
+    d = f.read()
+    f.close()
+    start = d.find("<axes>")
+    end = d.find("</axes>")+len("</axes>")
+    n = d[0:start] + d[end:]
+    f = open(path, 'w', encoding='utf-8')
+    f.write(n)
+    f.close()
+
+
+@pytest.fixture
+def invalid_designspace():
+    p = "testCheck.designspace"
+    __removeAxesFromDesignSpace(p)
+    yield p
+
+
+@pytest.mark.xfail(reason="The check method requires MutatorMath")
+def test_check(invalid_designspace, tmpdir):
+    tmpdir = str(tmpdir)
+    # check if the checks are checking
+    testDocPath = os.path.join(tmpdir, invalid_designspace)
+    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
+    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
+    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
+    instancePath2 = os.path.join(tmpdir, "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()
+    assert 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()
+    assert doc.getAxisOrder() == ['snap', 'pop']
+    assert doc.default == None
+    doc.checkDefault()
+    assert 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)
+    assert len(new.axes) == 2
+    new.checkAxes()
+    assert len(new.axes) == 2
+    assert print([a.name for a in new.axes]) == ['snap', 'pop']
+    new.write(testDocPath)