• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# -*- coding: utf-8 -*-
2
3from __future__ import print_function, division, absolute_import
4from fontTools.misc.py23 import *
5from fontTools.misc.loggingTools import LogMixin
6import collections
7import os
8import posixpath
9from fontTools.misc import etree as ET
10from fontTools.misc import plistlib
11
12"""
13    designSpaceDocument
14
15    - read and write designspace files
16"""
17
18__all__ = [
19    'DesignSpaceDocumentError', 'DesignSpaceDocument', 'SourceDescriptor',
20    'InstanceDescriptor', 'AxisDescriptor', 'RuleDescriptor', 'BaseDocReader',
21    'BaseDocWriter'
22]
23
24# ElementTree allows to find namespace-prefixed elements, but not attributes
25# so we have to do it ourselves for 'xml:lang'
26XML_NS = "{http://www.w3.org/XML/1998/namespace}"
27XML_LANG = XML_NS + "lang"
28
29
30def posix(path):
31    """Normalize paths using forward slash to work also on Windows."""
32    new_path = posixpath.join(*path.split(os.path.sep))
33    if path.startswith('/'):
34        # The above transformation loses absolute paths
35        new_path = '/' + new_path
36    return new_path
37
38
39def posixpath_property(private_name):
40    def getter(self):
41        # Normal getter
42        return getattr(self, private_name)
43
44    def setter(self, value):
45        # The setter rewrites paths using forward slashes
46        if value is not None:
47            value = posix(value)
48        setattr(self, private_name, value)
49
50    return property(getter, setter)
51
52
53class DesignSpaceDocumentError(Exception):
54    def __init__(self, msg, obj=None):
55        self.msg = msg
56        self.obj = obj
57
58    def __str__(self):
59        return str(self.msg) + (
60            ": %r" % self.obj if self.obj is not None else "")
61
62
63class AsDictMixin(object):
64
65    def asdict(self):
66        d = {}
67        for attr, value in self.__dict__.items():
68            if attr.startswith("_"):
69                continue
70            if hasattr(value, "asdict"):
71                value = value.asdict()
72            elif isinstance(value, list):
73                value = [
74                    v.asdict() if hasattr(v, "asdict") else v for v in value
75                ]
76            d[attr] = value
77        return d
78
79
80class SimpleDescriptor(AsDictMixin):
81    """ Containers for a bunch of attributes"""
82
83    # XXX this is ugly. The 'print' is inappropriate here, and instead of
84    # assert, it should simply return True/False
85    def compare(self, other):
86        # test if this object contains the same data as the other
87        for attr in self._attrs:
88            try:
89                assert(getattr(self, attr) == getattr(other, attr))
90            except AssertionError:
91                print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr))
92
93
94class SourceDescriptor(SimpleDescriptor):
95    """Simple container for data related to the source"""
96    flavor = "source"
97    _attrs = ['filename', 'path', 'name', 'layerName',
98              'location', 'copyLib',
99              'copyGroups', 'copyFeatures',
100              'muteKerning', 'muteInfo',
101              'mutedGlyphNames',
102              'familyName', 'styleName']
103
104    def __init__(self):
105        self.filename = None
106        """The original path as found in the document."""
107
108        self.path = None
109        """The absolute path, calculated from filename."""
110
111        self.font = None
112        """Any Python object. Optional. Points to a representation of this
113        source font that is loaded in memory, as a Python object (e.g. a
114        ``defcon.Font`` or a ``fontTools.ttFont.TTFont``).
115
116        The default document reader will not fill-in this attribute, and the
117        default writer will not use this attribute. It is up to the user of
118        ``designspaceLib`` to either load the resource identified by
119        ``filename`` and store it in this field, or write the contents of
120        this field to the disk and make ```filename`` point to that.
121        """
122
123        self.name = None
124        self.location = None
125        self.layerName = None
126        self.copyLib = False
127        self.copyInfo = False
128        self.copyGroups = False
129        self.copyFeatures = False
130        self.muteKerning = False
131        self.muteInfo = False
132        self.mutedGlyphNames = []
133        self.familyName = None
134        self.styleName = None
135
136    path = posixpath_property("_path")
137    filename = posixpath_property("_filename")
138
139
140class RuleDescriptor(SimpleDescriptor):
141    """<!-- optional: list of substitution rules -->
142    <rules>
143        <rule name="vertical.bars">
144            <conditionset>
145                <condition minimum="250.000000" maximum="750.000000" name="weight"/>
146                <condition minimum="100" name="width"/>
147                <condition minimum="10" maximum="40" name="optical"/>
148            </conditionset>
149            <sub name="cent" with="cent.alt"/>
150            <sub name="dollar" with="dollar.alt"/>
151        </rule>
152    </rules>
153    """
154    _attrs = ['name', 'conditionSets', 'subs']   # what do we need here
155
156    def __init__(self):
157        self.name = None
158        self.conditionSets = []  # list of list of dict(name='aaaa', minimum=0, maximum=1000)
159        self.subs = []  # list of substitutions stored as tuples of glyphnames ("a", "a.alt")
160
161
162def evaluateRule(rule, location):
163    """ Return True if any of the rule's conditionsets matches the given location."""
164    return any(evaluateConditions(c, location) for c in rule.conditionSets)
165
166
167def evaluateConditions(conditions, location):
168    """ Return True if all the conditions matches the given location.
169        If a condition has no minimum, check for < maximum.
170        If a condition has no maximum, check for > minimum.
171    """
172    for cd in conditions:
173        value = location[cd['name']]
174        if cd.get('minimum') is None:
175            if value > cd['maximum']:
176                return False
177        elif cd.get('maximum') is None:
178            if cd['minimum'] > value:
179                return False
180        elif not cd['minimum'] <= value <= cd['maximum']:
181            return False
182    return True
183
184
185def processRules(rules, location, glyphNames):
186    """ Apply these rules at this location to these glyphnames.minimum
187        - rule order matters
188    """
189    newNames = []
190    for rule in rules:
191        if evaluateRule(rule, location):
192            for name in glyphNames:
193                swap = False
194                for a, b in rule.subs:
195                    if name == a:
196                        swap = True
197                        break
198                if swap:
199                    newNames.append(b)
200                else:
201                    newNames.append(name)
202            glyphNames = newNames
203            newNames = []
204    return glyphNames
205
206
207class InstanceDescriptor(SimpleDescriptor):
208    """Simple container for data related to the instance"""
209    flavor = "instance"
210    _defaultLanguageCode = "en"
211    _attrs = ['path',
212              'name',
213              'location',
214              'familyName',
215              'styleName',
216              'postScriptFontName',
217              'styleMapFamilyName',
218              'styleMapStyleName',
219              'kerning',
220              'info',
221              'lib']
222
223    def __init__(self):
224        self.filename = None    # the original path as found in the document
225        self.path = None        # the absolute path, calculated from filename
226        self.name = None
227        self.location = None
228        self.familyName = None
229        self.styleName = None
230        self.postScriptFontName = None
231        self.styleMapFamilyName = None
232        self.styleMapStyleName = None
233        self.localisedStyleName = {}
234        self.localisedFamilyName = {}
235        self.localisedStyleMapStyleName = {}
236        self.localisedStyleMapFamilyName = {}
237        self.glyphs = {}
238        self.kerning = True
239        self.info = True
240
241        self.lib = {}
242        """Custom data associated with this instance."""
243
244    path = posixpath_property("_path")
245    filename = posixpath_property("_filename")
246
247    def setStyleName(self, styleName, languageCode="en"):
248        self.localisedStyleName[languageCode] = tounicode(styleName)
249
250    def getStyleName(self, languageCode="en"):
251        return self.localisedStyleName.get(languageCode)
252
253    def setFamilyName(self, familyName, languageCode="en"):
254        self.localisedFamilyName[languageCode] = tounicode(familyName)
255
256    def getFamilyName(self, languageCode="en"):
257        return self.localisedFamilyName.get(languageCode)
258
259    def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"):
260        self.localisedStyleMapStyleName[languageCode] = tounicode(styleMapStyleName)
261
262    def getStyleMapStyleName(self, languageCode="en"):
263        return self.localisedStyleMapStyleName.get(languageCode)
264
265    def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"):
266        self.localisedStyleMapFamilyName[languageCode] = tounicode(styleMapFamilyName)
267
268    def getStyleMapFamilyName(self, languageCode="en"):
269        return self.localisedStyleMapFamilyName.get(languageCode)
270
271
272def tagForAxisName(name):
273    # try to find or make a tag name for this axis name
274    names = {
275        'weight':   ('wght', dict(en = 'Weight')),
276        'width':    ('wdth', dict(en = 'Width')),
277        'optical':  ('opsz', dict(en = 'Optical Size')),
278        'slant':    ('slnt', dict(en = 'Slant')),
279        'italic':   ('ital', dict(en = 'Italic')),
280    }
281    if name.lower() in names:
282        return names[name.lower()]
283    if len(name) < 4:
284        tag = name + "*" * (4 - len(name))
285    else:
286        tag = name[:4]
287    return tag, dict(en=name)
288
289
290class AxisDescriptor(SimpleDescriptor):
291    """ Simple container for the axis data
292        Add more localisations?
293    """
294    flavor = "axis"
295    _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map']
296
297    def __init__(self):
298        self.tag = None       # opentype tag for this axis
299        self.name = None      # name of the axis used in locations
300        self.labelNames = {}  # names for UI purposes, if this is not a standard axis,
301        self.minimum = None
302        self.maximum = None
303        self.default = None
304        self.hidden = False
305        self.map = []
306
307    def serialize(self):
308        # output to a dict, used in testing
309        return dict(
310            tag=self.tag,
311            name=self.name,
312            labelNames=self.labelNames,
313            maximum=self.maximum,
314            minimum=self.minimum,
315            default=self.default,
316            hidden=self.hidden,
317            map=self.map,
318        )
319
320    def map_forward(self, v):
321        from fontTools.varLib.models import piecewiseLinearMap
322
323        if not self.map:
324            return v
325        return piecewiseLinearMap(v, {k: v for k, v in self.map})
326
327    def map_backward(self, v):
328        from fontTools.varLib.models import piecewiseLinearMap
329
330        if not self.map:
331            return v
332        return piecewiseLinearMap(v, {v: k for k, v in self.map})
333
334
335class BaseDocWriter(object):
336    _whiteSpace = "    "
337    ruleDescriptorClass = RuleDescriptor
338    axisDescriptorClass = AxisDescriptor
339    sourceDescriptorClass = SourceDescriptor
340    instanceDescriptorClass = InstanceDescriptor
341
342    @classmethod
343    def getAxisDecriptor(cls):
344        return cls.axisDescriptorClass()
345
346    @classmethod
347    def getSourceDescriptor(cls):
348        return cls.sourceDescriptorClass()
349
350    @classmethod
351    def getInstanceDescriptor(cls):
352        return cls.instanceDescriptorClass()
353
354    @classmethod
355    def getRuleDescriptor(cls):
356        return cls.ruleDescriptorClass()
357
358    def __init__(self, documentPath, documentObject):
359        self.path = documentPath
360        self.documentObject = documentObject
361        self.documentVersion = "4.0"
362        self.root = ET.Element("designspace")
363        self.root.attrib['format'] = self.documentVersion
364        self._axes = []     # for use by the writer only
365        self._rules = []    # for use by the writer only
366
367    def write(self, pretty=True, encoding="UTF-8", xml_declaration=True):
368        if self.documentObject.axes:
369            self.root.append(ET.Element("axes"))
370        for axisObject in self.documentObject.axes:
371            self._addAxis(axisObject)
372
373        if self.documentObject.rules:
374            self.root.append(ET.Element("rules"))
375        for ruleObject in self.documentObject.rules:
376            self._addRule(ruleObject)
377
378        if self.documentObject.sources:
379            self.root.append(ET.Element("sources"))
380        for sourceObject in self.documentObject.sources:
381            self._addSource(sourceObject)
382
383        if self.documentObject.instances:
384            self.root.append(ET.Element("instances"))
385        for instanceObject in self.documentObject.instances:
386            self._addInstance(instanceObject)
387
388        if self.documentObject.lib:
389            self._addLib(self.documentObject.lib)
390
391        tree = ET.ElementTree(self.root)
392        tree.write(
393            self.path,
394            encoding=encoding,
395            method='xml',
396            xml_declaration=xml_declaration,
397            pretty_print=pretty,
398        )
399
400    def _makeLocationElement(self, locationObject, name=None):
401        """ Convert Location dict to a locationElement."""
402        locElement = ET.Element("location")
403        if name is not None:
404            locElement.attrib['name'] = name
405        validatedLocation = self.documentObject.newDefaultLocation()
406        for axisName, axisValue in locationObject.items():
407            if axisName in validatedLocation:
408                # only accept values we know
409                validatedLocation[axisName] = axisValue
410        for dimensionName, dimensionValue in validatedLocation.items():
411            dimElement = ET.Element('dimension')
412            dimElement.attrib['name'] = dimensionName
413            if type(dimensionValue) == tuple:
414                dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue[0])
415                dimElement.attrib['yvalue'] = self.intOrFloat(dimensionValue[1])
416            else:
417                dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue)
418            locElement.append(dimElement)
419        return locElement, validatedLocation
420
421    def intOrFloat(self, num):
422        if int(num) == num:
423            return "%d" % num
424        return "%f" % num
425
426    def _addRule(self, ruleObject):
427        # if none of the conditions have minimum or maximum values, do not add the rule.
428        self._rules.append(ruleObject)
429        ruleElement = ET.Element('rule')
430        if ruleObject.name is not None:
431            ruleElement.attrib['name'] = ruleObject.name
432        for conditions in ruleObject.conditionSets:
433            conditionsetElement = ET.Element('conditionset')
434            for cond in conditions:
435                if cond.get('minimum') is None and cond.get('maximum') is None:
436                    # neither is defined, don't add this condition
437                    continue
438                conditionElement = ET.Element('condition')
439                conditionElement.attrib['name'] = cond.get('name')
440                if cond.get('minimum') is not None:
441                    conditionElement.attrib['minimum'] = self.intOrFloat(cond.get('minimum'))
442                if cond.get('maximum') is not None:
443                    conditionElement.attrib['maximum'] = self.intOrFloat(cond.get('maximum'))
444                conditionsetElement.append(conditionElement)
445            if len(conditionsetElement):
446                ruleElement.append(conditionsetElement)
447        for sub in ruleObject.subs:
448            subElement = ET.Element('sub')
449            subElement.attrib['name'] = sub[0]
450            subElement.attrib['with'] = sub[1]
451            ruleElement.append(subElement)
452        if len(ruleElement):
453            self.root.findall('.rules')[0].append(ruleElement)
454
455    def _addAxis(self, axisObject):
456        self._axes.append(axisObject)
457        axisElement = ET.Element('axis')
458        axisElement.attrib['tag'] = axisObject.tag
459        axisElement.attrib['name'] = axisObject.name
460        axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum)
461        axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum)
462        axisElement.attrib['default'] = self.intOrFloat(axisObject.default)
463        if axisObject.hidden:
464            axisElement.attrib['hidden'] = "1"
465        for languageCode, labelName in sorted(axisObject.labelNames.items()):
466            languageElement = ET.Element('labelname')
467            languageElement.attrib[XML_LANG] = languageCode
468            languageElement.text = labelName
469            axisElement.append(languageElement)
470        if axisObject.map:
471            for inputValue, outputValue in axisObject.map:
472                mapElement = ET.Element('map')
473                mapElement.attrib['input'] = self.intOrFloat(inputValue)
474                mapElement.attrib['output'] = self.intOrFloat(outputValue)
475                axisElement.append(mapElement)
476        self.root.findall('.axes')[0].append(axisElement)
477
478    def _addInstance(self, instanceObject):
479        instanceElement = ET.Element('instance')
480        if instanceObject.name is not None:
481            instanceElement.attrib['name'] = instanceObject.name
482        if instanceObject.familyName is not None:
483            instanceElement.attrib['familyname'] = instanceObject.familyName
484        if instanceObject.styleName is not None:
485            instanceElement.attrib['stylename'] = instanceObject.styleName
486        # add localisations
487        if instanceObject.localisedStyleName:
488            languageCodes = list(instanceObject.localisedStyleName.keys())
489            languageCodes.sort()
490            for code in languageCodes:
491                if code == "en":
492                    continue  # already stored in the element attribute
493                localisedStyleNameElement = ET.Element('stylename')
494                localisedStyleNameElement.attrib[XML_LANG] = code
495                localisedStyleNameElement.text = instanceObject.getStyleName(code)
496                instanceElement.append(localisedStyleNameElement)
497        if instanceObject.localisedFamilyName:
498            languageCodes = list(instanceObject.localisedFamilyName.keys())
499            languageCodes.sort()
500            for code in languageCodes:
501                if code == "en":
502                    continue  # already stored in the element attribute
503                localisedFamilyNameElement = ET.Element('familyname')
504                localisedFamilyNameElement.attrib[XML_LANG] = code
505                localisedFamilyNameElement.text = instanceObject.getFamilyName(code)
506                instanceElement.append(localisedFamilyNameElement)
507        if instanceObject.localisedStyleMapStyleName:
508            languageCodes = list(instanceObject.localisedStyleMapStyleName.keys())
509            languageCodes.sort()
510            for code in languageCodes:
511                if code == "en":
512                    continue
513                localisedStyleMapStyleNameElement = ET.Element('stylemapstylename')
514                localisedStyleMapStyleNameElement.attrib[XML_LANG] = code
515                localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code)
516                instanceElement.append(localisedStyleMapStyleNameElement)
517        if instanceObject.localisedStyleMapFamilyName:
518            languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys())
519            languageCodes.sort()
520            for code in languageCodes:
521                if code == "en":
522                    continue
523                localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname')
524                localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code
525                localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code)
526                instanceElement.append(localisedStyleMapFamilyNameElement)
527
528        if instanceObject.location is not None:
529            locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location)
530            instanceElement.append(locationElement)
531        if instanceObject.filename is not None:
532            instanceElement.attrib['filename'] = instanceObject.filename
533        if instanceObject.postScriptFontName is not None:
534            instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName
535        if instanceObject.styleMapFamilyName is not None:
536            instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName
537        if instanceObject.styleMapStyleName is not None:
538            instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName
539        if instanceObject.glyphs:
540            if instanceElement.findall('.glyphs') == []:
541                glyphsElement = ET.Element('glyphs')
542                instanceElement.append(glyphsElement)
543            glyphsElement = instanceElement.findall('.glyphs')[0]
544            for glyphName, data in sorted(instanceObject.glyphs.items()):
545                glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data)
546                glyphsElement.append(glyphElement)
547        if instanceObject.kerning:
548            kerningElement = ET.Element('kerning')
549            instanceElement.append(kerningElement)
550        if instanceObject.info:
551            infoElement = ET.Element('info')
552            instanceElement.append(infoElement)
553        if instanceObject.lib:
554            libElement = ET.Element('lib')
555            libElement.append(plistlib.totree(instanceObject.lib, indent_level=4))
556            instanceElement.append(libElement)
557        self.root.findall('.instances')[0].append(instanceElement)
558
559    def _addSource(self, sourceObject):
560        sourceElement = ET.Element("source")
561        if sourceObject.filename is not None:
562            sourceElement.attrib['filename'] = sourceObject.filename
563        if sourceObject.name is not None:
564            if sourceObject.name.find("temp_master") != 0:
565                # do not save temporary source names
566                sourceElement.attrib['name'] = sourceObject.name
567        if sourceObject.familyName is not None:
568            sourceElement.attrib['familyname'] = sourceObject.familyName
569        if sourceObject.styleName is not None:
570            sourceElement.attrib['stylename'] = sourceObject.styleName
571        if sourceObject.layerName is not None:
572            sourceElement.attrib['layer'] = sourceObject.layerName
573        if sourceObject.copyLib:
574            libElement = ET.Element('lib')
575            libElement.attrib['copy'] = "1"
576            sourceElement.append(libElement)
577        if sourceObject.copyGroups:
578            groupsElement = ET.Element('groups')
579            groupsElement.attrib['copy'] = "1"
580            sourceElement.append(groupsElement)
581        if sourceObject.copyFeatures:
582            featuresElement = ET.Element('features')
583            featuresElement.attrib['copy'] = "1"
584            sourceElement.append(featuresElement)
585        if sourceObject.copyInfo or sourceObject.muteInfo:
586            infoElement = ET.Element('info')
587            if sourceObject.copyInfo:
588                infoElement.attrib['copy'] = "1"
589            if sourceObject.muteInfo:
590                infoElement.attrib['mute'] = "1"
591            sourceElement.append(infoElement)
592        if sourceObject.muteKerning:
593            kerningElement = ET.Element("kerning")
594            kerningElement.attrib["mute"] = '1'
595            sourceElement.append(kerningElement)
596        if sourceObject.mutedGlyphNames:
597            for name in sourceObject.mutedGlyphNames:
598                glyphElement = ET.Element("glyph")
599                glyphElement.attrib["name"] = name
600                glyphElement.attrib["mute"] = '1'
601                sourceElement.append(glyphElement)
602        locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location)
603        sourceElement.append(locationElement)
604        self.root.findall('.sources')[0].append(sourceElement)
605
606    def _addLib(self, dict):
607        libElement = ET.Element('lib')
608        libElement.append(plistlib.totree(dict, indent_level=2))
609        self.root.append(libElement)
610
611    def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data):
612        glyphElement = ET.Element('glyph')
613        if data.get('mute'):
614            glyphElement.attrib['mute'] = "1"
615        if data.get('unicodes') is not None:
616            glyphElement.attrib['unicode'] = " ".join([hex(u) for u in data.get('unicodes')])
617        if data.get('instanceLocation') is not None:
618            locationElement, data['instanceLocation'] = self._makeLocationElement(data.get('instanceLocation'))
619            glyphElement.append(locationElement)
620        if glyphName is not None:
621            glyphElement.attrib['name'] = glyphName
622        if data.get('note') is not None:
623            noteElement = ET.Element('note')
624            noteElement.text = data.get('note')
625            glyphElement.append(noteElement)
626        if data.get('masters') is not None:
627            mastersElement = ET.Element("masters")
628            for m in data.get('masters'):
629                masterElement = ET.Element("master")
630                if m.get('glyphName') is not None:
631                    masterElement.attrib['glyphname'] = m.get('glyphName')
632                if m.get('font') is not None:
633                    masterElement.attrib['source'] = m.get('font')
634                if m.get('location') is not None:
635                    locationElement, m['location'] = self._makeLocationElement(m.get('location'))
636                    masterElement.append(locationElement)
637                mastersElement.append(masterElement)
638            glyphElement.append(mastersElement)
639        return glyphElement
640
641
642class BaseDocReader(LogMixin):
643    ruleDescriptorClass = RuleDescriptor
644    axisDescriptorClass = AxisDescriptor
645    sourceDescriptorClass = SourceDescriptor
646    instanceDescriptorClass = InstanceDescriptor
647
648    def __init__(self, documentPath, documentObject):
649        self.path = documentPath
650        self.documentObject = documentObject
651        tree = ET.parse(self.path)
652        self.root = tree.getroot()
653        self.documentObject.formatVersion = self.root.attrib.get("format", "3.0")
654        self._axes = []
655        self.rules = []
656        self.sources = []
657        self.instances = []
658        self.axisDefaults = {}
659        self._strictAxisNames = True
660
661    @classmethod
662    def fromstring(cls, string, documentObject):
663        f = BytesIO(tobytes(string, encoding="utf-8"))
664        self = cls(f, documentObject)
665        self.path = None
666        return self
667
668    def read(self):
669        self.readAxes()
670        self.readRules()
671        self.readSources()
672        self.readInstances()
673        self.readLib()
674
675    def readRules(self):
676        # we also need to read any conditions that are outside of a condition set.
677        rules = []
678        for ruleElement in self.root.findall(".rules/rule"):
679            ruleObject = self.ruleDescriptorClass()
680            ruleName = ruleObject.name = ruleElement.attrib.get("name")
681            # read any stray conditions outside a condition set
682            externalConditions = self._readConditionElements(
683                ruleElement,
684                ruleName,
685            )
686            if externalConditions:
687                ruleObject.conditionSets.append(externalConditions)
688                self.log.info(
689                    "Found stray rule conditions outside a conditionset. "
690                    "Wrapped them in a new conditionset."
691                )
692            # read the conditionsets
693            for conditionSetElement in ruleElement.findall('.conditionset'):
694                conditionSet = self._readConditionElements(
695                    conditionSetElement,
696                    ruleName,
697                )
698                if conditionSet is not None:
699                    ruleObject.conditionSets.append(conditionSet)
700            for subElement in ruleElement.findall('.sub'):
701                a = subElement.attrib['name']
702                b = subElement.attrib['with']
703                ruleObject.subs.append((a, b))
704            rules.append(ruleObject)
705        self.documentObject.rules = rules
706
707    def _readConditionElements(self, parentElement, ruleName=None):
708        cds = []
709        for conditionElement in parentElement.findall('.condition'):
710            cd = {}
711            cdMin = conditionElement.attrib.get("minimum")
712            if cdMin is not None:
713                cd['minimum'] = float(cdMin)
714            else:
715                # will allow these to be None, assume axis.minimum
716                cd['minimum'] = None
717            cdMax = conditionElement.attrib.get("maximum")
718            if cdMax is not None:
719                cd['maximum'] = float(cdMax)
720            else:
721                # will allow these to be None, assume axis.maximum
722                cd['maximum'] = None
723            cd['name'] = conditionElement.attrib.get("name")
724            # # test for things
725            if cd.get('minimum') is None and cd.get('maximum') is None:
726                raise DesignSpaceDocumentError(
727                    "condition missing required minimum or maximum in rule" +
728                    (" '%s'" % ruleName if ruleName is not None else ""))
729            cds.append(cd)
730        return cds
731
732    def readAxes(self):
733        # read the axes elements, including the warp map.
734        axisElements = self.root.findall(".axes/axis")
735        if not axisElements:
736            return
737        for axisElement in axisElements:
738            axisObject = self.axisDescriptorClass()
739            axisObject.name = axisElement.attrib.get("name")
740            axisObject.minimum = float(axisElement.attrib.get("minimum"))
741            axisObject.maximum = float(axisElement.attrib.get("maximum"))
742            if axisElement.attrib.get('hidden', False):
743                axisObject.hidden = True
744            axisObject.default = float(axisElement.attrib.get("default"))
745            axisObject.tag = axisElement.attrib.get("tag")
746            for mapElement in axisElement.findall('map'):
747                a = float(mapElement.attrib['input'])
748                b = float(mapElement.attrib['output'])
749                axisObject.map.append((a, b))
750            for labelNameElement in axisElement.findall('labelname'):
751                # Note: elementtree reads the "xml:lang" attribute name as
752                # '{http://www.w3.org/XML/1998/namespace}lang'
753                for key, lang in labelNameElement.items():
754                    if key == XML_LANG:
755                        axisObject.labelNames[lang] = tounicode(labelNameElement.text)
756            self.documentObject.axes.append(axisObject)
757            self.axisDefaults[axisObject.name] = axisObject.default
758        self.documentObject.defaultLoc = self.axisDefaults
759
760    def readSources(self):
761        for sourceCount, sourceElement in enumerate(self.root.findall(".sources/source")):
762            filename = sourceElement.attrib.get('filename')
763            if filename is not None and self.path is not None:
764                sourcePath = os.path.abspath(os.path.join(os.path.dirname(self.path), filename))
765            else:
766                sourcePath = None
767            sourceName = sourceElement.attrib.get('name')
768            if sourceName is None:
769                # add a temporary source name
770                sourceName = "temp_master.%d" % (sourceCount)
771            sourceObject = self.sourceDescriptorClass()
772            sourceObject.path = sourcePath        # absolute path to the ufo source
773            sourceObject.filename = filename      # path as it is stored in the document
774            sourceObject.name = sourceName
775            familyName = sourceElement.attrib.get("familyname")
776            if familyName is not None:
777                sourceObject.familyName = familyName
778            styleName = sourceElement.attrib.get("stylename")
779            if styleName is not None:
780                sourceObject.styleName = styleName
781            sourceObject.location = self.locationFromElement(sourceElement)
782            layerName = sourceElement.attrib.get('layer')
783            if layerName is not None:
784                sourceObject.layerName = layerName
785            for libElement in sourceElement.findall('.lib'):
786                if libElement.attrib.get('copy') == '1':
787                    sourceObject.copyLib = True
788            for groupsElement in sourceElement.findall('.groups'):
789                if groupsElement.attrib.get('copy') == '1':
790                    sourceObject.copyGroups = True
791            for infoElement in sourceElement.findall(".info"):
792                if infoElement.attrib.get('copy') == '1':
793                    sourceObject.copyInfo = True
794                if infoElement.attrib.get('mute') == '1':
795                    sourceObject.muteInfo = True
796            for featuresElement in sourceElement.findall(".features"):
797                if featuresElement.attrib.get('copy') == '1':
798                    sourceObject.copyFeatures = True
799            for glyphElement in sourceElement.findall(".glyph"):
800                glyphName = glyphElement.attrib.get('name')
801                if glyphName is None:
802                    continue
803                if glyphElement.attrib.get('mute') == '1':
804                    sourceObject.mutedGlyphNames.append(glyphName)
805            for kerningElement in sourceElement.findall(".kerning"):
806                if kerningElement.attrib.get('mute') == '1':
807                    sourceObject.muteKerning = True
808            self.documentObject.sources.append(sourceObject)
809
810    def locationFromElement(self, element):
811        elementLocation = None
812        for locationElement in element.findall('.location'):
813            elementLocation = self.readLocationElement(locationElement)
814            break
815        return elementLocation
816
817    def readLocationElement(self, locationElement):
818        """ Format 0 location reader """
819        if self._strictAxisNames and not self.documentObject.axes:
820            raise DesignSpaceDocumentError("No axes defined")
821        loc = {}
822        for dimensionElement in locationElement.findall(".dimension"):
823            dimName = dimensionElement.attrib.get("name")
824            if self._strictAxisNames and dimName not in self.axisDefaults:
825                # In case the document contains no axis definitions,
826                self.log.warning("Location with undefined axis: \"%s\".", dimName)
827                continue
828            xValue = yValue = None
829            try:
830                xValue = dimensionElement.attrib.get('xvalue')
831                xValue = float(xValue)
832            except ValueError:
833                self.log.warning("KeyError in readLocation xValue %3.3f", xValue)
834            try:
835                yValue = dimensionElement.attrib.get('yvalue')
836                if yValue is not None:
837                    yValue = float(yValue)
838            except ValueError:
839                pass
840            if yValue is not None:
841                loc[dimName] = (xValue, yValue)
842            else:
843                loc[dimName] = xValue
844        return loc
845
846    def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True):
847        instanceElements = self.root.findall('.instances/instance')
848        for instanceElement in instanceElements:
849            self._readSingleInstanceElement(instanceElement, makeGlyphs=makeGlyphs, makeKerning=makeKerning, makeInfo=makeInfo)
850
851    def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True):
852        filename = instanceElement.attrib.get('filename')
853        if filename is not None and self.documentObject.path is not None:
854            instancePath = os.path.join(os.path.dirname(self.documentObject.path), filename)
855        else:
856            instancePath = None
857        instanceObject = self.instanceDescriptorClass()
858        instanceObject.path = instancePath    # absolute path to the instance
859        instanceObject.filename = filename    # path as it is stored in the document
860        name = instanceElement.attrib.get("name")
861        if name is not None:
862            instanceObject.name = name
863        familyname = instanceElement.attrib.get('familyname')
864        if familyname is not None:
865            instanceObject.familyName = familyname
866        stylename = instanceElement.attrib.get('stylename')
867        if stylename is not None:
868            instanceObject.styleName = stylename
869        postScriptFontName = instanceElement.attrib.get('postscriptfontname')
870        if postScriptFontName is not None:
871            instanceObject.postScriptFontName = postScriptFontName
872        styleMapFamilyName = instanceElement.attrib.get('stylemapfamilyname')
873        if styleMapFamilyName is not None:
874            instanceObject.styleMapFamilyName = styleMapFamilyName
875        styleMapStyleName = instanceElement.attrib.get('stylemapstylename')
876        if styleMapStyleName is not None:
877            instanceObject.styleMapStyleName = styleMapStyleName
878        # read localised names
879        for styleNameElement in instanceElement.findall('stylename'):
880            for key, lang in styleNameElement.items():
881                if key == XML_LANG:
882                    styleName = styleNameElement.text
883                    instanceObject.setStyleName(styleName, lang)
884        for familyNameElement in instanceElement.findall('familyname'):
885            for key, lang in familyNameElement.items():
886                if key == XML_LANG:
887                    familyName = familyNameElement.text
888                    instanceObject.setFamilyName(familyName, lang)
889        for styleMapStyleNameElement in instanceElement.findall('stylemapstylename'):
890            for key, lang in styleMapStyleNameElement.items():
891                if key == XML_LANG:
892                    styleMapStyleName = styleMapStyleNameElement.text
893                    instanceObject.setStyleMapStyleName(styleMapStyleName, lang)
894        for styleMapFamilyNameElement in instanceElement.findall('stylemapfamilyname'):
895            for key, lang in styleMapFamilyNameElement.items():
896                if key == XML_LANG:
897                    styleMapFamilyName = styleMapFamilyNameElement.text
898                    instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang)
899        instanceLocation = self.locationFromElement(instanceElement)
900        if instanceLocation is not None:
901            instanceObject.location = instanceLocation
902        for glyphElement in instanceElement.findall('.glyphs/glyph'):
903            self.readGlyphElement(glyphElement, instanceObject)
904        for infoElement in instanceElement.findall("info"):
905            self.readInfoElement(infoElement, instanceObject)
906        for libElement in instanceElement.findall('lib'):
907            self.readLibElement(libElement, instanceObject)
908        self.documentObject.instances.append(instanceObject)
909
910    def readLibElement(self, libElement, instanceObject):
911        """Read the lib element for the given instance."""
912        instanceObject.lib = plistlib.fromtree(libElement[0])
913
914    def readInfoElement(self, infoElement, instanceObject):
915        """ Read the info element."""
916        instanceObject.info = True
917
918    def readKerningElement(self, kerningElement, instanceObject):
919        """ Read the kerning element."""
920        kerningLocation = self.locationFromElement(kerningElement)
921        instanceObject.addKerning(kerningLocation)
922
923    def readGlyphElement(self, glyphElement, instanceObject):
924        """
925        Read the glyph element.
926            <glyph name="b" unicode="0x62"/>
927            <glyph name="b"/>
928            <glyph name="b">
929                <master location="location-token-bbb" source="master-token-aaa2"/>
930                <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/>
931                <note>
932                    This is an instance from an anisotropic interpolation.
933                </note>
934            </glyph>
935        """
936        glyphData = {}
937        glyphName = glyphElement.attrib.get('name')
938        if glyphName is None:
939            raise DesignSpaceDocumentError("Glyph object without name attribute")
940        mute = glyphElement.attrib.get("mute")
941        if mute == "1":
942            glyphData['mute'] = True
943        # unicode
944        unicodes = glyphElement.attrib.get('unicode')
945        if unicodes is not None:
946            try:
947                unicodes = [int(u, 16) for u in unicodes.split(" ")]
948                glyphData['unicodes'] = unicodes
949            except ValueError:
950                raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes)
951
952        for noteElement in glyphElement.findall('.note'):
953            glyphData['note'] = noteElement.text
954            break
955        instanceLocation = self.locationFromElement(glyphElement)
956        if instanceLocation is not None:
957            glyphData['instanceLocation'] = instanceLocation
958        glyphSources = None
959        for masterElement in glyphElement.findall('.masters/master'):
960            fontSourceName = masterElement.attrib.get('source')
961            sourceLocation = self.locationFromElement(masterElement)
962            masterGlyphName = masterElement.attrib.get('glyphname')
963            if masterGlyphName is None:
964                # if we don't read a glyphname, use the one we have
965                masterGlyphName = glyphName
966            d = dict(font=fontSourceName,
967                     location=sourceLocation,
968                     glyphName=masterGlyphName)
969            if glyphSources is None:
970                glyphSources = []
971            glyphSources.append(d)
972        if glyphSources is not None:
973            glyphData['masters'] = glyphSources
974        instanceObject.glyphs[glyphName] = glyphData
975
976    def readLib(self):
977        """Read the lib element for the whole document."""
978        for libElement in self.root.findall(".lib"):
979            self.documentObject.lib = plistlib.fromtree(libElement[0])
980
981
982class DesignSpaceDocument(LogMixin, AsDictMixin):
983    """ Read, write data from the designspace file"""
984    def __init__(self, readerClass=None, writerClass=None):
985        self.path = None
986        self.filename = None
987        """String, optional. When the document is read from the disk, this is
988        its original file name, i.e. the last part of its path.
989
990        When the document is produced by a Python script and still only exists
991        in memory, the producing script can write here an indication of a
992        possible "good" filename, in case one wants to save the file somewhere.
993        """
994
995        self.formatVersion = None
996        self.sources = []
997        self.instances = []
998        self.axes = []
999        self.rules = []
1000        self.default = None         # name of the default master
1001        self.defaultLoc = None
1002
1003        self.lib = {}
1004        """Custom data associated with the whole document."""
1005
1006        #
1007        if readerClass is not None:
1008            self.readerClass = readerClass
1009        else:
1010            self.readerClass = BaseDocReader
1011        if writerClass is not None:
1012            self.writerClass = writerClass
1013        else:
1014            self.writerClass = BaseDocWriter
1015
1016    @classmethod
1017    def fromfile(cls, path, readerClass=None, writerClass=None):
1018        self = cls(readerClass=readerClass, writerClass=writerClass)
1019        self.read(path)
1020        return self
1021
1022    @classmethod
1023    def fromstring(cls, string, readerClass=None, writerClass=None):
1024        self = cls(readerClass=readerClass, writerClass=writerClass)
1025        reader = self.readerClass.fromstring(string, self)
1026        reader.read()
1027        if self.sources:
1028            self.findDefault()
1029        return self
1030
1031    def tostring(self, encoding=None):
1032        if encoding is unicode or (
1033            encoding is not None and encoding.lower() == "unicode"
1034        ):
1035            f = UnicodeIO()
1036            xml_declaration = False
1037        elif encoding is None or encoding == "utf-8":
1038            f = BytesIO()
1039            encoding = "UTF-8"
1040            xml_declaration = True
1041        else:
1042            raise ValueError("unsupported encoding: '%s'" % encoding)
1043        writer = self.writerClass(f, self)
1044        writer.write(encoding=encoding, xml_declaration=xml_declaration)
1045        return f.getvalue()
1046
1047    def read(self, path):
1048        if hasattr(path, "__fspath__"):  # support os.PathLike objects
1049            path = path.__fspath__()
1050        self.path = path
1051        self.filename = os.path.basename(path)
1052        reader = self.readerClass(path, self)
1053        reader.read()
1054        if self.sources:
1055            self.findDefault()
1056
1057    def write(self, path):
1058        if hasattr(path, "__fspath__"):  # support os.PathLike objects
1059            path = path.__fspath__()
1060        self.path = path
1061        self.filename = os.path.basename(path)
1062        self.updatePaths()
1063        writer = self.writerClass(path, self)
1064        writer.write()
1065
1066    def _posixRelativePath(self, otherPath):
1067        relative = os.path.relpath(otherPath, os.path.dirname(self.path))
1068        return posix(relative)
1069
1070    def updatePaths(self):
1071        """
1072            Right before we save we need to identify and respond to the following situations:
1073            In each descriptor, we have to do the right thing for the filename attribute.
1074
1075            case 1.
1076            descriptor.filename == None
1077            descriptor.path == None
1078
1079            -- action:
1080            write as is, descriptors will not have a filename attr.
1081            useless, but no reason to interfere.
1082
1083
1084            case 2.
1085            descriptor.filename == "../something"
1086            descriptor.path == None
1087
1088            -- action:
1089            write as is. The filename attr should not be touched.
1090
1091
1092            case 3.
1093            descriptor.filename == None
1094            descriptor.path == "~/absolute/path/there"
1095
1096            -- action:
1097            calculate the relative path for filename.
1098            We're not overwriting some other value for filename, it should be fine
1099
1100
1101            case 4.
1102            descriptor.filename == '../somewhere'
1103            descriptor.path == "~/absolute/path/there"
1104
1105            -- action:
1106            there is a conflict between the given filename, and the path.
1107            So we know where the file is relative to the document.
1108            Can't guess why they're different, we just choose for path to be correct and update filename.
1109
1110
1111        """
1112        assert self.path is not None
1113        for descriptor in self.sources + self.instances:
1114            if descriptor.path is not None:
1115                # case 3 and 4: filename gets updated and relativized
1116                descriptor.filename = self._posixRelativePath(descriptor.path)
1117
1118    def addSource(self, sourceDescriptor):
1119        self.sources.append(sourceDescriptor)
1120
1121    def addInstance(self, instanceDescriptor):
1122        self.instances.append(instanceDescriptor)
1123
1124    def addAxis(self, axisDescriptor):
1125        self.axes.append(axisDescriptor)
1126
1127    def addRule(self, ruleDescriptor):
1128        self.rules.append(ruleDescriptor)
1129
1130    def newDefaultLocation(self):
1131        """Return default location in design space."""
1132        # Without OrderedDict, output XML would be non-deterministic.
1133        # https://github.com/LettError/designSpaceDocument/issues/10
1134        loc = collections.OrderedDict()
1135        for axisDescriptor in self.axes:
1136            loc[axisDescriptor.name] = axisDescriptor.map_forward(
1137                axisDescriptor.default
1138            )
1139        return loc
1140
1141    def updateFilenameFromPath(self, masters=True, instances=True, force=False):
1142        # set a descriptor filename attr from the path and this document path
1143        # if the filename attribute is not None: skip it.
1144        if masters:
1145            for descriptor in self.sources:
1146                if descriptor.filename is not None and not force:
1147                    continue
1148                if self.path is not None:
1149                    descriptor.filename = self._posixRelativePath(descriptor.path)
1150        if instances:
1151            for descriptor in self.instances:
1152                if descriptor.filename is not None and not force:
1153                    continue
1154                if self.path is not None:
1155                    descriptor.filename = self._posixRelativePath(descriptor.path)
1156
1157    def newAxisDescriptor(self):
1158        # Ask the writer class to make us a new axisDescriptor
1159        return self.writerClass.getAxisDecriptor()
1160
1161    def newSourceDescriptor(self):
1162        # Ask the writer class to make us a new sourceDescriptor
1163        return self.writerClass.getSourceDescriptor()
1164
1165    def newInstanceDescriptor(self):
1166        # Ask the writer class to make us a new instanceDescriptor
1167        return self.writerClass.getInstanceDescriptor()
1168
1169    def getAxisOrder(self):
1170        names = []
1171        for axisDescriptor in self.axes:
1172            names.append(axisDescriptor.name)
1173        return names
1174
1175    def getAxis(self, name):
1176        for axisDescriptor in self.axes:
1177            if axisDescriptor.name == name:
1178                return axisDescriptor
1179        return None
1180
1181    def findDefault(self):
1182        """Set and return SourceDescriptor at the default location or None.
1183
1184        The default location is the set of all `default` values in user space
1185        of all axes.
1186        """
1187        self.default = None
1188
1189        # Convert the default location from user space to design space before comparing
1190        # it against the SourceDescriptor locations (always in design space).
1191        default_location_design = {
1192            axis.name: axis.map_forward(self.defaultLoc[axis.name])
1193            for axis in self.axes
1194        }
1195
1196        for sourceDescriptor in self.sources:
1197            if sourceDescriptor.location == default_location_design:
1198                self.default = sourceDescriptor
1199                return sourceDescriptor
1200
1201        return None
1202
1203    def normalizeLocation(self, location):
1204        from fontTools.varLib.models import normalizeValue
1205
1206        new = {}
1207        for axis in self.axes:
1208            if axis.name not in location:
1209                # skipping this dimension it seems
1210                continue
1211            value = location[axis.name]
1212            # 'anisotropic' location, take first coord only
1213            if isinstance(value, tuple):
1214                value = value[0]
1215            triple = [
1216                axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum)
1217            ]
1218            new[axis.name] = normalizeValue(value, triple)
1219        return new
1220
1221    def normalize(self):
1222        # Normalise the geometry of this designspace:
1223        #   scale all the locations of all masters and instances to the -1 - 0 - 1 value.
1224        #   we need the axis data to do the scaling, so we do those last.
1225        # masters
1226        for item in self.sources:
1227            item.location = self.normalizeLocation(item.location)
1228        # instances
1229        for item in self.instances:
1230            # glyph masters for this instance
1231            for _, glyphData in item.glyphs.items():
1232                glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation'])
1233                for glyphMaster in glyphData['masters']:
1234                    glyphMaster['location'] = self.normalizeLocation(glyphMaster['location'])
1235            item.location = self.normalizeLocation(item.location)
1236        # the axes
1237        for axis in self.axes:
1238            # scale the map first
1239            newMap = []
1240            for inputValue, outputValue in axis.map:
1241                newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name)
1242                newMap.append((inputValue, newOutputValue))
1243            if newMap:
1244                axis.map = newMap
1245            # finally the axis values
1246            minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name)
1247            maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name)
1248            default = self.normalizeLocation({axis.name: axis.default}).get(axis.name)
1249            # and set them in the axis.minimum
1250            axis.minimum = minimum
1251            axis.maximum = maximum
1252            axis.default = default
1253        # now the rules
1254        for rule in self.rules:
1255            newConditionSets = []
1256            for conditions in rule.conditionSets:
1257                newConditions = []
1258                for cond in conditions:
1259                    if cond.get('minimum') is not None:
1260                        minimum = self.normalizeLocation({cond['name']: cond['minimum']}).get(cond['name'])
1261                    else:
1262                        minimum = None
1263                    if cond.get('maximum') is not None:
1264                        maximum = self.normalizeLocation({cond['name']: cond['maximum']}).get(cond['name'])
1265                    else:
1266                        maximum = None
1267                    newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum))
1268                newConditionSets.append(newConditions)
1269            rule.conditionSets = newConditionSets
1270