diff --git a/.gitignore b/.gitignore index 73b271321..afeef5372 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ htmlcov/ # OSX Finder .DS_Store + diff --git a/Lib/designSpaceDocument/__init__.py b/Lib/designSpaceDocument/__init__.py new file mode 100644 index 000000000..a723d64e8 --- /dev/null +++ b/Lib/designSpaceDocument/__init__.py @@ -0,0 +1,2264 @@ +# -*- 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'] + + +class DesignSpaceDocumentError(Exception): + def __init__(self, msg, obj=None): + self.msg = msg + self.obj = obj + + def __str__(self): + return repr(self.msg) + repr(self.obj) + + +def _indent(elem, whitespace=" ", level=0): + # taken from http://effbot.org/zone/element-lib.htm#prettyprint + i = "\n" + level * whitespace + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + whitespace + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + _indent(elem, whitespace, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + + +class SimpleDescriptor(object): + """ Containers for a bunch of attributes""" + def compare(self, other): + # test if this object contains the same data as the other + for attr in self._attrs: + try: + assert(getattr(self, attr) == getattr(other, attr)) + except AssertionError: + print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr)) + + +class SourceDescriptor(SimpleDescriptor): + """Simple container for data related to the source""" + flavor = "source" + _attrs = ['filename', 'path', 'name', + 'location', 'copyLib', + 'copyGroups', 'copyFeatures', + 'muteKerning', 'muteInfo', + 'mutedGlyphNames', + 'familyName', 'styleName'] + + def __init__(self): + self.filename = None # the original path as found in the document + self.path = None # the absolute path, calculated from filename + self.name = None + self.location = None + self.copyLib = False + self.copyInfo = False + self.copyGroups = False + self.copyFeatures = False + self.muteKerning = False + self.muteInfo = False + self.mutedGlyphNames = [] + self.familyName = None + self.styleName = None + + +class RuleDescriptor(SimpleDescriptor): + """ + + + + + + + + + + + Discussion: + use axis names rather than tags - then we can evaluate the rule without having to look up the axes. + remove the subs from the rule. + remove 'enabled' attr form rule + + + """ + _attrs = ['name', 'conditions', 'subs'] # what do we need here + def __init__(self): + self.name = None + self.conditions = [] # list of dict(tag='aaaa', minimum=0, maximum=1000) + self.subs = [] # list of substitutions stored as tuples of glyphnames ("a", "a.alt") + +def evaluateRule(rule, location): + """ Test if rule is True at location.maximum + If a condition has no minimum, check for < maximum. + If a condition has no maximum, check for > minimum. + """ + for cd in rule.conditions: + if not cd['name'] in location: + continue + if cd.get('minimum') is None: + if not location[cd['name']] <= cd['maximum']: + return False + elif cd.get('maximum') is None: + if not cd['minimum'] <= location[cd['name']]: + return False + else: + if not cd['minimum'] <= location[cd['name']] <= cd['maximum']: + return False + return True + +def processRules(rules, location, glyphNames): + """ Apply these rules at this location to these glyphnames.minimum + - rule order matters + """ + newNames = [] + for rule in rules: + if evaluateRule(rule, location): + for name in glyphNames: + swap = False + for a, b in rule.subs: + if name == a: + swap = True + break + if swap: + newNames.append(b) + else: + newNames.append(name) + glyphNames = newNames + newNames = [] + return glyphNames + + + + +class InstanceDescriptor(SimpleDescriptor): + """Simple container for data related to the instance""" + flavor = "instance" + _defaultLanguageCode = "en" + _attrs = [ 'path', + 'name', + 'location', + 'familyName', + 'styleName', + 'postScriptFontName', + 'styleMapFamilyName', + 'styleMapStyleName', + 'kerning', + 'info'] + + 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 + + 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 = instanceObject.localisedStyleName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue # already stored in the element attribute + localisedStyleNameElement = ET.Element('stylename') + localisedStyleNameElement.attrib["xml:lang"] = code + localisedStyleNameElement.text = instanceObject.getStyleName(code) + instanceElement.append(localisedStyleNameElement) + if instanceObject.localisedFamilyName: + languageCodes = instanceObject.localisedFamilyName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue # already stored in the element attribute + localisedFamilyNameElement = ET.Element('familyname') + localisedFamilyNameElement.attrib["xml:lang"] = code + localisedFamilyNameElement.text = instanceObject.getFamilyName(code) + instanceElement.append(localisedFamilyNameElement) + if instanceObject.localisedStyleMapStyleName: + languageCodes = instanceObject.localisedStyleMapStyleName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue + localisedStyleMapStyleNameElement = ET.Element('stylemapstylename') + localisedStyleMapStyleNameElement.attrib["xml:lang"] = code + localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code) + instanceElement.append(localisedStyleMapStyleNameElement) + if instanceObject.localisedStyleMapFamilyName: + languageCodes = instanceObject.localisedStyleMapFamilyName.keys() + languageCodes.sort() + for code in languageCodes: + if code == "en": continue + localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname') + localisedStyleMapFamilyNameElement.attrib["xml:lang"] = code + localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code) + instanceElement.append(localisedStyleMapFamilyNameElement) + + if instanceObject.location is not None: + locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) + instanceElement.append(locationElement) + if instanceObject.filename is not None: + instanceElement.attrib['filename'] = instanceObject.filename + if instanceObject.postScriptFontName is not None: + instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName + if instanceObject.styleMapFamilyName is not None: + instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName + if instanceObject.styleMapStyleName is not None: + instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName + if instanceObject.glyphs: + if instanceElement.findall('.glyphs') == []: + glyphsElement = ET.Element('glyphs') + instanceElement.append(glyphsElement) + glyphsElement = instanceElement.findall('.glyphs')[0] + for glyphName, data in instanceObject.glyphs.items(): + glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) + glyphsElement.append(glyphElement) + if instanceObject.kerning: + kerningElement = ET.Element('kerning') + instanceElement.append(kerningElement) + if instanceObject.info: + infoElement = ET.Element('info') + instanceElement.append(infoElement) + 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. + + :: + + + + Let's drop support for a different location for the info. Never needed it. + + """ + infoLocation = self.locationFromElement(infoElement) + instanceObject.info = True + + def readKerningElement(self, kerningElement, instanceObject): + """ Read the kerning element. + + :: + + Make kerning at the location and with the masters specified at the instance level. + + + """ + kerningLocation = self.locationFromElement(kerningElement) + instanceObject.addKerning(kerningLocation) + + def readGlyphElement(self, glyphElement, instanceObject): + """ + Read the glyph element. + + :: + + + + + + + + + + + This is an instance from an anisotropic interpolation. + + + + """ + glyphData = {} + glyphName = glyphElement.attrib.get('name') + if glyphName is None: + raise DesignSpaceDocumentError("Glyph object without name attribute.") + mute = glyphElement.attrib.get("mute") + if mute == "1": + glyphData['mute'] = True + # unicode + unicodes = glyphElement.attrib.get('unicode') + if unicodes is not None: + try: + unicodes = [int(u, 16) for u in unicodes.split(" ")] + glyphData['unicodes'] = unicodes + except ValueError: + raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes) + + note = None + for noteElement in glyphElement.findall('.note'): + glyphData['note'] = noteElement.text + break + instanceLocation = self.locationFromElement(glyphElement) + if instanceLocation is not None: + glyphData['instanceLocation'] = instanceLocation + glyphSources = None + for masterElement in glyphElement.findall('.masters/master'): + fontSourceName = masterElement.attrib.get('source') + sourceLocation = self.locationFromElement(masterElement) + masterGlyphName = masterElement.attrib.get('glyphname') + if masterGlyphName is None: + # if we don't read a glyphname, use the one we have + masterGlyphName = glyphName + d = dict(font=fontSourceName, + location=sourceLocation, + glyphName=masterGlyphName) + if glyphSources is None: + glyphSources = [] + glyphSources.append(d) + if glyphSources is not None: + glyphData['masters'] = glyphSources + instanceObject.glyphs[glyphName] = glyphData + + +class DesignSpaceDocument(object): + """ Read, write data from the designspace file""" + def __init__(self, readerClass=None, writerClass=None, fontClass=None): + self.logger = logging.getLogger("DesignSpaceDocumentLog") + self.path = None + self.formatVersion = None + self.sources = [] + self.instances = [] + self.axes = [] + self.rules = [] + self.default = None # name of the default master + self.defaultLoc = None + # + if readerClass is not None: + self.readerClass = readerClass + else: + self.readerClass = BaseDocReader + if writerClass is not None: + self.writerClass = writerClass + else: + self.writerClass = BaseDocWriter + if fontClass is not None: + self.fontClass = fontClass + else: + from defcon.objects.font import Font + self.fontClass = Font + + def read(self, path): + self.path = path + reader = self.readerClass(path, self) + reader.read() + + def write(self, path): + self.path = path + self.updatePaths() + writer = self.writerClass(path, self) + writer.write() + + def _posixRelativePath(self, otherPath): + relative = os.path.relpath(otherPath, os.path.dirname(self.path)) + return posixpath.join(*relative.split(os.path.sep)) + + def updatePaths(self): + """ + Right before we save we need to identify and respond to the following situations: + In each descriptor, we have to do the right thing for the filename attribute. + + case 1. + descriptor.filename == None + descriptor.path == None + + -- action: + write as is, descriptors will not have a filename attr. + useless, but no reason to interfere. + + + case 2. + descriptor.filename == "../something" + descriptor.path == None + + -- action: + write as is. The filename attr should not be touched. + + + case 3. + descriptor.filename == None + descriptor.path == "~/absolute/path/there" + + -- action: + calculate the relative path for filename. + We're not overwriting some other value for filename, it should be fine + + + case 4. + descriptor.filename == '../somewhere' + descriptor.path == "~/absolute/path/there" + + -- action: + there is a conflict between the given filename, and the path. + So we know where the file is relative to the document. + Can't guess why they're different, we just choose for path to be correct and update filename. + + + """ + for descriptor in self.sources + self.instances: + # check what the relative path really should be? + expectedFilename = None + if descriptor.path is not None and self.path is not None: + expectedFilename = self._posixRelativePath(descriptor.path) + + # 3 + if descriptor.filename is None and descriptor.path is not None and self.path is not None: + descriptor.filename = self._posixRelativePath(descriptor.path) + continue + + # 4 + if descriptor.filename is not None and descriptor.path is not None and self.path is not None: + if descriptor.filename is not expectedFilename: + descriptor.filename = expectedFilename + + def addSource(self, sourceDescriptor): + self.sources.append(sourceDescriptor) + + def addInstance(self, instanceDescriptor): + self.instances.append(instanceDescriptor) + + def addAxis(self, axisDescriptor): + self.axes.append(axisDescriptor) + + def addRule(self, ruleDescriptor): + self.rules.append(ruleDescriptor) + + def newDefaultLocation(self): + loc = {} + for axisDescriptor in self.axes: + loc[axisDescriptor.name] = axisDescriptor.default + return loc + + def updateFilenameFromPath(self, masters=True, instances=True, force=False): + # set a descriptor filename attr from the path and this document path + # if the filename attribute is not None: skip it. + if masters: + for descriptor in self.sources: + if descriptor.filename is not None and not force: + continue + if self.path is not None: + descriptor.filename = self._posixRelativePath(descriptor.path) + if instances: + for descriptor in self.instances: + if descriptor.filename is not None and not force: + continue + if self.path is not None: + descriptor.filename = self._posixRelativePath(descriptor.path) + + def getFonts(self): + # convenience method that delivers the masters and their locations + # so someone can build a thing for a thing. + fonts = [] + for sourceDescriptor in self.sources: + if sourceDescriptor.path is not None: + if os.path.exists(sourceDescriptor.path): + f = self.fontClass(sourceDescriptor.path) + fonts.append((f, sourceDescriptor.location)) + return fonts + + def newAxisDescriptor(self): + # Ask the writer class to make us a new axisDescriptor + return self.writerClass.getAxisDecriptor() + + 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 + masterLocations = [src.location for src in self.sources] + mutatorBias = biasFromLocations(masterLocations, preferOrigin=False) + c = [src for src in self.sources if src.location==mutatorBias] + if c: + mutatorDefaultCandidate = c[0] + else: + mutatorDefaultCandidate = None + # what are we going to do? + if flaggedDefaultCandidate is not None: + if mutatorDefaultCandidate is not None: + if mutatorDefaultCandidate.name != flaggedDefaultCandidate.name: + # warn if we have a conflict + self.logger.info("Note: conflicting default masters:\n\tUsing %s as default\n\tMutator found %s"%(flaggedDefaultCandidate.name, mutatorDefaultCandidate.name)) + self.default = flaggedDefaultCandidate + self.defaultLoc = self.default.location + else: + # we have no flagged default candidate + # let's use the one from mutator + if flaggedDefaultCandidate is None and mutatorDefaultCandidate is not None: + # we didn't have a flag, use the one selected by mutator + self.default = mutatorDefaultCandidate + self.defaultLoc = self.default.location + self.default.copyInfo = True + # now that we have a default, let's check if the axes are ok + for axisObj in self.axes: + if axisObj.name not in self.default.location: + # extend the location of the neutral master with missing default value for this axis + self.default.location[axisObj.name] = axisObj.default + else: + if axisObj.default == self.default.location.get(axisObj.name): + continue + # proposed remedy: change default value in the axisdescriptor to the value of the neutral + neutralAxisValue = self.default.location.get(axisObj.name) + # make sure this value is between the min and max + if axisObj.minimum <= neutralAxisValue <= axisObj.maximum: + # yes we can fix this + axisObj.default = neutralAxisValue + self.logger.info("Note: updating the default value of axis %s to neutral master at %3.3f"%(axisObj.name, neutralAxisValue)) + # always fit the axis dimensions to the location of the designated neutral + elif neutralAxisValue < axisObj.minimum: + axisObj.default = neutralAxisValue + axisObj.minimum = neutralAxisValue + elif neutralAxisValue > axisObj.maximum: + axisObj.maximum = neutralAxisValue + axisObj.default = neutralAxisValue + else: + # now we're in trouble, can't solve this, alert. + self.logger.info("Warning: mismatched default value for axis %s and neutral master. Master value outside of axis bounds"%(axisObj.name)) + + + def _prepAxesForBender(self): + """ + Make the axis data we have available in + """ + benderAxes = {} + for axisDescriptor in self.axes: + d = { + 'name': axisDescriptor.name, + 'tag': axisDescriptor.tag, + 'minimum': axisDescriptor.minimum, + 'maximum': axisDescriptor.maximum, + 'default': axisDescriptor.default, + 'map': axisDescriptor.map, + } + benderAxes[axisDescriptor.name] = d + return benderAxes + + def checkAxes(self, overwrite=False): + """ + If we don't have axes in the document, make some, report + Should we include the instance locations when determining the axis extrema? + """ + axisValues = {} + # find all the axes + locations = [] + for sourceDescriptor in self.sources: + locations.append(sourceDescriptor.location) + for instanceDescriptor in self.instances: + locations.append(instanceDescriptor.location) + for name, glyphData in instanceDescriptor.glyphs.items(): + loc = glyphData.get("instanceLocation") + if loc is not None: + locations.append(loc) + for m in glyphData.get('masters', []): + locations.append(m['location']) + for loc in locations: + for name, value in loc.items(): + if not name in axisValues: + axisValues[name] = [] + if type(value)==tuple: + for v in value: + axisValues[name].append(v) + else: + axisValues[name].append(value) + have = self.getAxisOrder() + for name, values in axisValues.items(): + a = None + if name in have: + if overwrite: + # we have the axis, + a = self.getAxis(name) + else: + continue + else: + # we need to make this axis + a = self.newAxisDescriptor() + self.addAxis(a) + a.name = name + a.minimum = min(values) + a.maximum = max(values) + a.default = a.minimum + a.tag, a.labelNames = tagForAxisName(a.name) + self.logger.info("CheckAxes: added a missing axis %s, %3.3f %3.3f", a.name, a.minimum, a.maximum) + + + def normalizeLocation(self, location): + # scale this location based on the axes + # accept only values for the axes that we have definitions for + # only normalise if we're valid? + # normalise anisotropic cooordinates to isotropic. + # copied from fontTools.varlib.models.normalizeLocation + new = {} + for axis in self.axes: + if not axis.name in location: + # skipping this dimension it seems + continue + v = location.get(axis.name, axis.default) + if type(v)==tuple: + v = v[0] + if v == axis.default: + v = 0.0 + elif v < axis.default: + if axis.default == axis.minimum: + v = 0.0 + else: + v = (max(v, axis.minimum) - axis.default) / (axis.default - axis.minimum) + else: + if axis.default == axis.maximum: + v = 0.0 + else: + v = (min(v, axis.maximum) - axis.default) / (axis.maximum - axis.default) + new[axis.name] = v + return new + + def normalize(self): + # scale all the locations of all masters and instances to the -1 - 0 - 1 value. + # we need the axis data to do the scaling, so we do those last. + # masters + for item in self.sources: + item.location = self.normalizeLocation(item.location) + # instances + for item in self.instances: + # glyph masters for this instance + for name, glyphData in item.glyphs.items(): + glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation']) + for glyphMaster in glyphData['masters']: + glyphMaster['location'] = self.normalizeLocation(glyphMaster['location']) + item.location = self.normalizeLocation(item.location) + # now the axes + for axis in self.axes: + # scale the map first + newMap = [] + for inputValue, outputValue in axis.map: + newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name) + newMap.append((inputValue, newOutputValue)) + if newMap: + axis.map = newMap + # finally the axis values + minimum = self.normalizeLocation({axis.name:axis.minimum}).get(axis.name) + maximum = self.normalizeLocation({axis.name:axis.maximum}).get(axis.name) + default = self.normalizeLocation({axis.name:axis.default}).get(axis.name) + # and set them in the axis.minimum + axis.minimum = minimum + axis.maximum = maximum + axis.default = default + # now the rules + for rule in self.rules: + newConditions = [] + for cond in rule.conditions: + if cond.get('minimum') is not None: + minimum = self.normalizeLocation({cond['name']:cond['minimum']}).get(cond['name']) + else: + minimum = None + if cond.get('maximum') is not None: + maximum = self.normalizeLocation({cond['name']:cond['maximum']}).get(cond['name']) + else: + maximum = None + newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum)) + rule.conditions = newConditions + + +def rulesToFeature(doc, whiteSpace="\t", newLine="\n"): + """ Showing how rules could be expressed as FDK feature text. + Speculative. Experimental. + """ + axisNames = {axis.name: axis.tag for axis in doc.axes} + axisDims = {axis.tag: (axis.minimum, axis.maximum) for axis in doc.axes} + text = [] + for rule in doc.rules: + text.append("rule %s{"%rule.name) + for cd in rule.conditions: + axisTag = axisNames.get(cd.get('name'), "****") + axisMinimum = cd.get('minimum', axisDims.get(axisTag, [0,0])[0]) + axisMaximum = cd.get('maximum', axisDims.get(axisTag, [0,0])[1]) + text.append("%s%s %f %f;"%(whiteSpace, axisTag, axisMinimum, axisMaximum)) + text.append("} %s;"%rule.name) + return newLine.join(text) + +if __name__ == "__main__": + + def __removeAxesFromDesignSpace(path): + # only for testing, so we can make an invalid designspace file + # without making the designSpaceDocument also support it. + f = open(path, 'r') + d = f.read() + f.close() + start = d.find("") + end = d.find("")+len("") + n = d[0:start] + d[end:] + f = open(path, 'w') + f.write(n) + f.close() + + def test(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "test.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyLib = True + >>> s1.copyInfo = True + >>> s1.copyFeatures = True + >>> s1.location = dict(weight=0) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> s1.mutedGlyphNames.append("A") + >>> s1.mutedGlyphNames.append("Z") + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo2" + >>> s2.copyLib = False + >>> s2.copyInfo = False + >>> s2.copyFeatures = False + >>> s2.muteKerning = True + >>> s2.location = dict(weight=1000) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.familyName = "InstanceFamilyName" + >>> i1.styleName = "InstanceStyleName" + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(weight=500, spooky=666) # this adds a dimension that is not defined. + >>> i1.postScriptFontName = "InstancePostscriptName" + >>> i1.styleMapFamilyName = "InstanceStyleMapFamilyName" + >>> i1.styleMapStyleName = "InstanceStyleMapStyleName" + >>> glyphData = dict(name="arrow", mute=True, unicodes=[0x123, 0x124, 0x125]) + >>> i1.glyphs['arrow'] = glyphData + >>> doc.addInstance(i1) + >>> # add instance 2 + >>> i2 = InstanceDescriptor() + >>> i2.filename = os.path.relpath(instancePath2, os.path.dirname(testDocPath)) + >>> i2.familyName = "InstanceFamilyName" + >>> i2.styleName = "InstanceStyleName" + >>> i2.name = "instance.ufo2" + >>> # anisotropic location + >>> i2.location = dict(weight=500, width=(400,300)) + >>> i2.postScriptFontName = "InstancePostscriptName" + >>> i2.styleMapFamilyName = "InstanceStyleMapFamilyName" + >>> i2.styleMapStyleName = "InstanceStyleMapStyleName" + >>> glyphMasters = [dict(font="master.ufo1", glyphName="BB", location=dict(width=20,weight=20)), dict(font="master.ufo2", glyphName="CC", location=dict(width=900,weight=900))] + >>> glyphData = dict(name="arrow", unicodes=[101, 201, 301]) + >>> glyphData['masters'] = glyphMasters + >>> glyphData['note'] = "A note about this glyph" + >>> glyphData['instanceLocation'] = dict(width=100, weight=120) + >>> i2.glyphs['arrow'] = glyphData + >>> i2.glyphs['arrow2'] = dict(mute=False) + >>> doc.addInstance(i2) + >>> # now we have sources and instances, but no axes yet. + >>> doc.check() + >>> doc.getAxisOrder() + ['spooky', 'weight', 'width'] + >>> doc.axes = [] # clear the axes + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> # note: just to test the element language, not an actual label name recommendations. + >>> a1.labelNames[u'fa-IR'] = u"قطر" + >>> a1.labelNames[u'en'] = u"Wéíght" + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.minimum = 0 + >>> a2.maximum = 1000 + >>> a2.default = 20 + >>> a2.name = "width" + >>> a2.tag = "wdth" + >>> a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> a2.hidden = True + >>> a2.labelNames[u'fr'] = u"Poids" + >>> doc.addAxis(a2) + >>> # add an axis that is not part of any location to see if that works + >>> a3 = AxisDescriptor() + >>> a3.minimum = 333 + >>> a3.maximum = 666 + >>> a3.default = 444 + >>> a3.name = "spooky" + >>> a3.tag = "spok" + >>> a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> #doc.addAxis(a3) # uncomment this line to test the effects of default axes values + >>> # write some rules + >>> r1 = RuleDescriptor() + >>> r1.name = "named.rule.1" + >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1)) + >>> r1.conditions.append(dict(name='bbbb', minimum=2, maximum=3)) + >>> r1.subs.append(("a", "a.alt")) + >>> doc.addRule(r1) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.check() + >>> new.default.location + {'width': 20.0, 'weight': 0.0} + + # >>> for a, b in zip(doc.instances, new.instances): + # ... a.compare(b) + # >>> for a, b in zip(doc.sources, new.sources): + # ... a.compare(b) + # >>> for a, b in zip(doc.axes, new.axes): + # ... a.compare(b) + # >>> [n.mutedGlyphNames for n in new.sources] + # [['A', 'Z'], []] + # >>> doc.getFonts() + # [] + + >>> # test roundtrip for the axis attributes and data + >>> axes = {} + >>> for axis in doc.axes: + ... if not axis.tag in axes: + ... axes[axis.tag] = [] + ... axes[axis.tag].append(axis.serialize()) + >>> for axis in new.axes: + ... if axis.tag[0] == "_": continue + ... if not axis.tag in axes: + ... axes[axis.tag] = [] + ... axes[axis.tag].append(axis.serialize()) + >>> for v in axes.values(): + ... a, b = v + ... assert a == b + + """ + + def testAdjustAxisDefaultToNeutral(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testAdjustAxisDefaultToNeutral.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyInfo = True + >>> s1.copyFeatures = True + >>> s1.location = dict(weight=55, width=1000) + >>> doc.addSource(s1) + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 # the wrong value + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.minimum = -10 + >>> a2.maximum = 10 + >>> a2.default = 0 # the wrong value + >>> a2.name = "width" + >>> a2.tag = "wdth" + >>> doc.addAxis(a2) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.check() + >>> loc = new.default.location + >>> for axisObj in new.axes: + ... n = axisObj.name + ... assert axisObj.default == loc.get(n) + """ + + def testUnicodes(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testUnicodes.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testUnicodes_roundtrip.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyInfo = True + >>> s1.location = dict(weight=0) + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo2" + >>> s2.location = dict(weight=1000) + >>> doc.addSource(s2) + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(weight=500) + >>> glyphData = dict(name="arrow", mute=True, unicodes=[100, 200, 300]) + >>> i1.glyphs['arrow'] = glyphData + >>> doc.addInstance(i1) + >>> # now we have sources and instances, but no axes yet. + >>> doc.axes = [] # clear the axes + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> doc.addAxis(a1) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.write(testDocPath2) + >>> # compare the file contents + >>> f1 = open(testDocPath, 'r') + >>> t1 = f1.read() + >>> f1.close() + >>> f2 = open(testDocPath2, 'r') + >>> t2 = f2.read() + >>> f2.close() + >>> t1 == t2 + True + >>> # check the unicode values read from the document + >>> new.instances[0].glyphs['arrow']['unicodes'] == [100,200,300] + True + """ + + def testLocalisedNames(): + u""" + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testLocalisedNames.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testLocalisedNames_roundtrip.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyInfo = True + >>> s1.location = dict(weight=0) + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo2" + >>> s2.location = dict(weight=1000) + >>> doc.addSource(s2) + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.familyName = "Montserrat" + >>> i1.styleName = "SemiBold" + >>> i1.styleMapFamilyName = "Montserrat SemiBold" + >>> i1.styleMapStyleName = "Regular" + >>> i1.setFamilyName("Montserrat", "fr") + >>> i1.setFamilyName(u"モンセラート", "ja") + >>> i1.setStyleName("Demigras", "fr") + >>> i1.setStyleName(u"半ば", "ja") + >>> i1.setStyleMapStyleName(u"Standard", "de") + >>> i1.setStyleMapFamilyName("Montserrat Halbfett", "de") + >>> i1.setStyleMapFamilyName(u"モンセラート SemiBold", "ja") + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(weight=500, spooky=666) # this adds a dimension that is not defined. + >>> i1.postScriptFontName = "InstancePostscriptName" + >>> glyphData = dict(name="arrow", mute=True, unicodes=[0x123]) + >>> i1.glyphs['arrow'] = glyphData + >>> doc.addInstance(i1) + >>> # now we have sources and instances, but no axes yet. + >>> doc.axes = [] # clear the axes + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "weight" + >>> a1.tag = "wght" + >>> # note: just to test the element language, not an actual label name recommendations. + >>> a1.labelNames[u'fa-IR'] = u"قطر" + >>> a1.labelNames[u'en'] = u"Wéíght" + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.minimum = 0 + >>> a2.maximum = 1000 + >>> a2.default = 0 + >>> a2.name = "width" + >>> a2.tag = "wdth" + >>> a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> a2.labelNames[u'fr'] = u"Poids" + >>> doc.addAxis(a2) + >>> # add an axis that is not part of any location to see if that works + >>> a3 = AxisDescriptor() + >>> a3.minimum = 333 + >>> a3.maximum = 666 + >>> a3.default = 444 + >>> a3.name = "spooky" + >>> a3.tag = "spok" + >>> a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] + >>> #doc.addAxis(a3) # uncomment this line to test the effects of default axes values + >>> # write some rules + >>> r1 = RuleDescriptor() + >>> r1.name = "named.rule.1" + >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1)) + >>> r1.conditions.append(dict(name='bbbb', minimum=2, maximum=3)) + >>> r1.subs.append(("a", "a.alt")) + >>> doc.addRule(r1) + >>> # write the document + >>> doc.write(testDocPath) + >>> assert os.path.exists(testDocPath) + >>> # import it again + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> new.write(testDocPath2) + >>> f1 = open(testDocPath, 'r') + >>> t1 = f1.read() + >>> f1.close() + >>> f2 = open(testDocPath2, 'r') + >>> t2 = f2.read() + >>> f2.close() + >>> assert t1 == t2 + + """ + + def testHandleNoAxes(): + # test what happens if the designspacedocument has no axes element. + """ + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testNoAxes_source.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testNoAxes_recontructed.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + + # Case 1: No axes element in the document, but there are sources and instances + >>> doc = DesignSpaceDocument() + + >>> for name, value in [('One', 1),('Two', 2),('Three', 3)]: + ... a = AxisDescriptor() + ... a.minimum = 0 + ... a.maximum = 1000 + ... a.default = 0 + ... a.name = "axisName%s"%(name) + ... a.tag = "ax_%d"%(value) + ... doc.addAxis(a) + + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath)) + >>> s1.name = "master.ufo1" + >>> s1.copyLib = True + >>> s1.copyInfo = True + >>> s1.copyFeatures = True + >>> s1.location = dict(axisNameOne=-1000, axisNameTwo=0, axisNameThree=1000) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath)) + >>> s2.name = "master.ufo1" + >>> s2.copyLib = False + >>> s2.copyInfo = False + >>> s2.copyFeatures = False + >>> s2.location = dict(axisNameOne=1000, axisNameTwo=1000, axisNameThree=0) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + + >>> # add instance 1 + >>> i1 = InstanceDescriptor() + >>> i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath)) + >>> i1.familyName = "InstanceFamilyName" + >>> i1.styleName = "InstanceStyleName" + >>> i1.name = "instance.ufo1" + >>> i1.location = dict(axisNameOne=(-1000,500), axisNameTwo=100) + >>> i1.postScriptFontName = "InstancePostscriptName" + >>> i1.styleMapFamilyName = "InstanceStyleMapFamilyName" + >>> i1.styleMapStyleName = "InstanceStyleMapStyleName" + >>> doc.addInstance(i1) + + >>> doc.write(testDocPath) + >>> __removeAxesFromDesignSpace(testDocPath) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath) + >>> verify.write(testDocPath2) + """ + + def testPathNameResolve(): + # test how descriptor.path and descriptor.filename are resolved + """ + >>> import os + >>> testDocPath1 = os.path.join(os.getcwd(), "testPathName_case1.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testPathName_case2.designspace") + >>> testDocPath3 = os.path.join(os.getcwd(), "testPathName_case3.designspace") + >>> testDocPath4 = os.path.join(os.getcwd(), "testPathName_case4.designspace") + >>> testDocPath5 = os.path.join(os.getcwd(), "testPathName_case5.designspace") + >>> testDocPath6 = os.path.join(os.getcwd(), "testPathName_case6.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + + # Case 1: filename and path are both empty. Nothing to calculate, nothing to put in the file. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = None + >>> s.path = None + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath1) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath1) + >>> assert verify.sources[0].filename == None + >>> assert verify.sources[0].path == None + + # Case 2: filename is empty, path points somewhere: calculate a new filename. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = None + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath2) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath2) + >>> assert verify.sources[0].filename == "masters/masterTest1.ufo" + >>> assert verify.sources[0].path == masterPath1 + + # Case 3: the filename is set, the path is None. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = "../somewhere/over/the/rainbow.ufo" + >>> s.path = None + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath3) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath3) + >>> assert verify.sources[0].filename == "../somewhere/over/the/rainbow.ufo" + >>> # make the absolute path for filename so we can see if it matches the path + >>> p = os.path.abspath(os.path.join(os.path.dirname(testDocPath3), verify.sources[0].filename)) + >>> assert verify.sources[0].path == p + + # Case 4: the filename points to one file, the path points to another. The path takes precedence. + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = "../somewhere/over/the/rainbow.ufo" + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath4) + >>> verify = DesignSpaceDocument() + >>> verify.read(testDocPath4) + >>> assert verify.sources[0].filename == "masters/masterTest1.ufo" + + # Case 5: the filename is None, path has a value, update the filename + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = None + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.addSource(s) + >>> doc.write(testDocPath5) # so that the document has a path + >>> doc.updateFilenameFromPath() + >>> assert doc.sources[0].filename == "masters/masterTest1.ufo" + + # Case 6: the filename has a value, path has a value, update the filenames with force + >>> doc = DesignSpaceDocument() + >>> s = SourceDescriptor() + >>> s.filename = "../somewhere/over/the/rainbow.ufo" + >>> s.path = masterPath1 + >>> s.copyInfo = True + >>> s.location = dict(weight=0) + >>> s.familyName = "MasterFamilyName" + >>> s.styleName = "MasterStyleNameOne" + >>> doc.write(testDocPath5) # so that the document has a path + >>> doc.addSource(s) + >>> assert doc.sources[0].filename == "../somewhere/over/the/rainbow.ufo" + >>> doc.updateFilenameFromPath(force=True) + >>> assert doc.sources[0].filename == "masters/masterTest1.ufo" + + """ + + def testNormalise(): + """ + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.minimum = -1000 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "aaa" + >>> a1.tag = "aaaa" + >>> doc.addAxis(a1) + + >>> doc.normalizeLocation(dict(aaa=0)) + {'aaa': 0.0} + >>> doc.normalizeLocation(dict(aaa=1000)) + {'aaa': 1.0} + >>> # clipping beyond max values: + >>> doc.normalizeLocation(dict(aaa=1001)) + {'aaa': 1.0} + >>> doc.normalizeLocation(dict(aaa=500)) + {'aaa': 0.5} + >>> doc.normalizeLocation(dict(aaa=-1000)) + {'aaa': -1.0} + >>> doc.normalizeLocation(dict(aaa=-1001)) + {'aaa': -1.0} + >>> # anisotropic coordinates normalise to isotropic + >>> doc.normalizeLocation(dict(aaa=(1000,-1000))) + {'aaa': 1.0} + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('aaa', -1.0, 0.0, 1.0)] + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a2 = AxisDescriptor() + >>> a2.minimum = 100 + >>> a2.maximum = 1000 + >>> a2.default = 100 + >>> a2.name = "bbb" + >>> doc.addAxis(a2) + >>> doc.normalizeLocation(dict(bbb=0)) + {'bbb': 0.0} + >>> doc.normalizeLocation(dict(bbb=1000)) + {'bbb': 1.0} + >>> # clipping beyond max values: + >>> doc.normalizeLocation(dict(bbb=1001)) + {'bbb': 1.0} + >>> doc.normalizeLocation(dict(bbb=500)) + {'bbb': 0.4444444444444444} + >>> doc.normalizeLocation(dict(bbb=-1000)) + {'bbb': 0.0} + >>> doc.normalizeLocation(dict(bbb=-1001)) + {'bbb': 0.0} + >>> # anisotropic coordinates normalise to isotropic + >>> doc.normalizeLocation(dict(bbb=(1000,-1000))) + {'bbb': 1.0} + >>> doc.normalizeLocation(dict(bbb=1001)) + {'bbb': 1.0} + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('bbb', 0.0, 0.0, 1.0)] + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a3 = AxisDescriptor() + >>> a3.minimum = -1000 + >>> a3.maximum = 0 + >>> a3.default = 0 + >>> a3.name = "ccc" + >>> doc.addAxis(a3) + >>> doc.normalizeLocation(dict(ccc=0)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=1)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=-1000)) + {'ccc': -1.0} + >>> doc.normalizeLocation(dict(ccc=-1001)) + {'ccc': -1.0} + + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('ccc', -1.0, 0.0, 0.0)] + + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a3 = AxisDescriptor() + >>> a3.minimum = 2000 + >>> a3.maximum = 3000 + >>> a3.default = 2000 + >>> a3.name = "ccc" + >>> doc.addAxis(a3) + >>> doc.normalizeLocation(dict(ccc=0)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=1)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=-1000)) + {'ccc': 0.0} + >>> doc.normalizeLocation(dict(ccc=-1001)) + {'ccc': 0.0} + + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.minimum, axis.default, axis.maximum)) + >>> r.sort() + >>> r + [('ccc', 0.0, 0.0, 1.0)] + + + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a4 = AxisDescriptor() + >>> a4.minimum = 0 + >>> a4.maximum = 1000 + >>> a4.default = 0 + >>> a4.name = "ddd" + >>> a4.map = [(0,100), (300, 500), (600, 500), (1000,900)] + >>> doc.addAxis(a4) + >>> doc.normalize() + >>> r = [] + >>> for axis in doc.axes: + ... r.append((axis.name, axis.map)) + >>> r.sort() + >>> r + [('ddd', [(0, 0.1), (300, 0.5), (600, 0.5), (1000, 0.9)])] + + + """ + + def testCheck(): + """ + >>> # check if the checks are checking + >>> testDocPath = os.path.join(os.getcwd(), "testCheck.designspace") + >>> masterPath1 = os.path.join(os.getcwd(), "masters", "masterTest1.ufo") + >>> masterPath2 = os.path.join(os.getcwd(), "masters", "masterTest2.ufo") + >>> instancePath1 = os.path.join(os.getcwd(), "instances", "instanceTest1.ufo") + >>> instancePath2 = os.path.join(os.getcwd(), "instances", "instanceTest2.ufo") + + >>> # no default selected + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.path = masterPath1 + >>> s1.name = "master.ufo1" + >>> s1.location = dict(snap=0, pop=10) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.path = masterPath2 + >>> s2.name = "master.ufo2" + >>> s2.location = dict(snap=1000, pop=20) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> doc.checkAxes() + >>> doc.getAxisOrder() + ['snap', 'pop'] + >>> assert doc.default == None + >>> doc.checkDefault() + >>> doc.default.name + 'master.ufo1' + + >>> # default selected + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.path = masterPath1 + >>> s1.name = "master.ufo1" + >>> s1.location = dict(snap=0, pop=10) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.path = masterPath2 + >>> s2.name = "master.ufo2" + >>> s2.copyInfo = True + >>> s2.location = dict(snap=1000, pop=20) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> doc.checkAxes() + >>> doc.getAxisOrder() + ['snap', 'pop'] + >>> assert doc.default == None + >>> doc.checkDefault() + >>> doc.default.name + 'master.ufo2' + + >>> # generate a doc without axes, save and read again + >>> doc = DesignSpaceDocument() + >>> # add master 1 + >>> s1 = SourceDescriptor() + >>> s1.path = masterPath1 + >>> s1.name = "master.ufo1" + >>> s1.location = dict(snap=0, pop=10) + >>> s1.familyName = "MasterFamilyName" + >>> s1.styleName = "MasterStyleNameOne" + >>> doc.addSource(s1) + >>> # add master 2 + >>> s2 = SourceDescriptor() + >>> s2.path = masterPath2 + >>> s2.name = "master.ufo2" + >>> s2.location = dict(snap=1000, pop=20) + >>> s2.familyName = "MasterFamilyName" + >>> s2.styleName = "MasterStyleNameTwo" + >>> doc.addSource(s2) + >>> doc.checkAxes() + >>> doc.write(testDocPath) + >>> __removeAxesFromDesignSpace(testDocPath) + + >>> new = DesignSpaceDocument() + >>> new.read(testDocPath) + >>> len(new.axes) + 2 + >>> new.checkAxes() + >>> len(new.axes) + 2 + >>> print([a.name for a in new.axes]) + ['snap', 'pop'] + >>> new.write(testDocPath) + + """ + + def testRules(): + """ + >>> import os + >>> testDocPath = os.path.join(os.getcwd(), "testRules.designspace") + >>> testDocPath2 = os.path.join(os.getcwd(), "testRules_roundtrip.designspace") + >>> doc = DesignSpaceDocument() + >>> # write some axes + >>> a1 = AxisDescriptor() + >>> a1.tag = "taga" + >>> a1.name = "aaaa" + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> doc.addAxis(a1) + >>> a2 = AxisDescriptor() + >>> a2.tag = "tagb" + >>> a2.name = "bbbb" + >>> a2.minimum = 0 + >>> a2.maximum = 3000 + >>> a2.default = 0 + >>> doc.addAxis(a2) + + >>> r1 = RuleDescriptor() + >>> r1.name = "named.rule.1" + >>> r1.conditions.append(dict(name='aaaa', minimum=0, maximum=1000)) + >>> r1.conditions.append(dict(name='bbbb', minimum=0, maximum=3000)) + >>> r1.subs.append(("a", "a.alt")) + >>> + >>> # rule with minium and maximum + >>> doc.addRule(r1) + >>> assert len(doc.rules) == 1 + >>> assert len(doc.rules[0].conditions) == 2 + >>> evaluateRule(r1, dict(aaaa = 500, bbbb = 0)) + True + >>> evaluateRule(r1, dict(aaaa = 0, bbbb = 0)) + True + >>> evaluateRule(r1, dict(aaaa = 1000, bbbb = 0)) + True + >>> evaluateRule(r1, dict(aaaa = 1000, bbbb = -100)) + False + >>> evaluateRule(r1, dict(aaaa = 1000.0001, bbbb = 0)) + False + >>> evaluateRule(r1, dict(aaaa = -0.0001, bbbb = 0)) + False + >>> evaluateRule(r1, dict(aaaa = -100, bbbb = 0)) + False + >>> processRules([r1], dict(aaaa = 500), ["a", "b", "c"]) + ['a.alt', 'b', 'c'] + >>> processRules([r1], dict(aaaa = 500), ["a.alt", "b", "c"]) + ['a.alt', 'b', 'c'] + >>> processRules([r1], dict(aaaa = 2000), ["a", "b", "c"]) + ['a', 'b', 'c'] + + >>> # rule with only a maximum + >>> r2 = RuleDescriptor() + >>> r2.name = "named.rule.2" + >>> r2.conditions.append(dict(name='aaaa', maximum=500)) + >>> r2.subs.append(("b", "b.alt")) + >>> + >>> evaluateRule(r2, dict(aaaa = 0)) + True + >>> evaluateRule(r2, dict(aaaa = -500)) + True + >>> evaluateRule(r2, dict(aaaa = 1000)) + False + + >>> # rule with only a minimum + >>> r3 = RuleDescriptor() + >>> r3.name = "named.rule.3" + >>> r3.conditions.append(dict(name='aaaa', minimum=500)) + >>> r3.subs.append(("c", "c.alt")) + >>> + >>> evaluateRule(r3, dict(aaaa = 0)) + False + >>> evaluateRule(r3, dict(aaaa = 1000)) + True + >>> evaluateRule(r3, dict(bbbb = 1000)) + True + + >>> # rule with only a minimum, maximum in separate conditions + >>> r4 = RuleDescriptor() + >>> r4.name = "named.rule.4" + >>> r4.conditions.append(dict(name='aaaa', minimum=500)) + >>> r4.conditions.append(dict(name='bbbb', maximum=500)) + >>> r4.subs.append(("c", "c.alt")) + >>> + >>> evaluateRule(r4, dict()) # is this what we expect though? + True + >>> evaluateRule(r4, dict(aaaa = 1000, bbbb = 0)) + True + >>> evaluateRule(r4, dict(aaaa = 0, bbbb = 0)) + False + >>> evaluateRule(r4, dict(aaaa = 1000, bbbb = 1000)) + False + + >>> a1 = AxisDescriptor() + >>> a1.minimum = 0 + >>> a1.maximum = 1000 + >>> a1.default = 0 + >>> a1.name = "aaaa" + >>> a1.tag = "aaaa" + >>> b1 = AxisDescriptor() + >>> b1.minimum = 2000 + >>> b1.maximum = 3000 + >>> b1.default = 2000 + >>> b1.name = "bbbb" + >>> b1.tag = "bbbb" + >>> doc.addAxis(a1) + >>> doc.addAxis(b1) + >>> doc._prepAxesForBender() + {'aaaa': {'map': [], 'name': 'aaaa', 'default': 0, 'minimum': 0, 'maximum': 1000, 'tag': 'aaaa'}, 'bbbb': {'map': [], 'name': 'bbbb', 'default': 2000, 'minimum': 2000, 'maximum': 3000, 'tag': 'bbbb'}} + + + >>> doc.rules[0].conditions + [{'minimum': 0, 'maximum': 1000, 'name': 'aaaa'}, {'minimum': 0, 'maximum': 3000, 'name': 'bbbb'}] + + >>> doc.rules[0].subs + [('a', 'a.alt')] + + >>> doc.normalize() + >>> doc.rules[0].name + 'named.rule.1' + >>> doc.rules[0].conditions + [{'minimum': 0.0, 'maximum': 1.0, 'name': 'aaaa'}, {'minimum': 0.0, 'maximum': 1.0, 'name': 'bbbb'}] + + >>> doc.write(testDocPath) + >>> new = DesignSpaceDocument() + + >>> new.read(testDocPath) + >>> len(new.axes) + 4 + >>> len(new.rules) + 1 + >>> new.write(testDocPath2) + + """ + + p = "testCheck.designspace" + __removeAxesFromDesignSpace(p) + def _test(): + import doctest + doctest.testmod() + _test() diff --git a/Lib/designSpaceDocument/testLocalisedNames.designspace b/Lib/designSpaceDocument/testLocalisedNames.designspace new file mode 100644 index 000000000..2b96886e0 --- /dev/null +++ b/Lib/designSpaceDocument/testLocalisedNames.designspace @@ -0,0 +1,57 @@ + + + + + قطر + Wéíght + + + Poids + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Demigras + 半ば + Montserrat + モンセラート + Standard + Montserrat Halbfett + モンセラート SemiBold + + + + + + + + + + + + diff --git a/Lib/designSpaceDocument/testLocalisedNames_roundtrip.designspace b/Lib/designSpaceDocument/testLocalisedNames_roundtrip.designspace new file mode 100644 index 000000000..2b96886e0 --- /dev/null +++ b/Lib/designSpaceDocument/testLocalisedNames_roundtrip.designspace @@ -0,0 +1,57 @@ + + + + + قطر + Wéíght + + + Poids + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Demigras + 半ば + Montserrat + モンセラート + Standard + Montserrat Halbfett + モンセラート SemiBold + + + + + + + + + + + + diff --git a/Lib/designSpaceDocument/testRules.designspace b/Lib/designSpaceDocument/testRules.designspace new file mode 100644 index 000000000..94108de67 --- /dev/null +++ b/Lib/designSpaceDocument/testRules.designspace @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Lib/designSpaceDocument/testRules_roundtrip.designspace b/Lib/designSpaceDocument/testRules_roundtrip.designspace new file mode 100644 index 000000000..94108de67 --- /dev/null +++ b/Lib/designSpaceDocument/testRules_roundtrip.designspace @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Lib/designSpaceDocument/ufoProcessor.py b/Lib/designSpaceDocument/ufoProcessor.py new file mode 100644 index 000000000..9f6291dd3 --- /dev/null +++ b/Lib/designSpaceDocument/ufoProcessor.py @@ -0,0 +1,766 @@ + +# coding: utf-8 +from __future__ import print_function, division, absolute_import + +from ufoLib import fontInfoAttributesVersion1, fontInfoAttributesVersion2, fontInfoAttributesVersion3 +from pprint import pprint +import logging + +""" + + A subclassed DesignSpaceDocument that can + - process the document and generate finished UFOs with MutatorMath. + - read and write documents + - bypass and eventually replace the mutatormath ufo generator. + +""" + + +from designSpaceDocument import DesignSpaceDocument, SourceDescriptor, InstanceDescriptor, AxisDescriptor, RuleDescriptor, processRules +from defcon.objects.font import Font +from defcon.pens.transformPointPen import TransformPointPen +from defcon.objects.component import _defaultTransformation +import defcon +from fontMath.mathGlyph import MathGlyph +from fontMath.mathInfo import MathInfo +from fontMath.mathKerning import MathKerning +from mutatorMath.objects.mutator import buildMutator +from mutatorMath.objects.location import biasFromLocations, Location +import plistlib +import os + +""" + + Swap the contents of two glyphs. + - contours + - components + - width + - group membership + - kerning + + + Remap components so that glyphs that reference either of the swapped glyphs maintain appearance + + Keep the unicode value of the original glyph. + + Notes + Parking the glyphs under a swapname is a bit lazy, but at least it guarantees the glyphs have the right parent. + +""" + + +""" These are some UFO specific tools for use with Mutator. + + + build() is a convenience function for reading and executing a designspace file. + documentPath: filepath to the .designspace document + outputUFOFormatVersion: ufo format for output + verbose: True / False for lots or no feedback + logPath: filepath to a log file + progressFunc: an optional callback to report progress. + see mutatorMath.ufo.tokenProgressFunc + +""" + +def build( + documentPath, + outputUFOFormatVersion=3, + roundGeometry=True, + verbose=True, # not supported + logPath=None, # not supported + progressFunc=None, # not supported + processRules=True, + logger=None + ): + """ + Simple builder for UFO designspaces. + """ + import os, glob + if os.path.isdir(documentPath): + # process all *.designspace documents in this folder + todo = glob.glob(os.path.join(documentPath, "*.designspace")) + else: + # process the + todo = [documentPath] + results = [] + for path in todo: + reader = DesignSpaceProcessor(ufoVersion=outputUFOFormatVersion) + reader.roundGeometry = roundGeometry + reader.read(path) + try: + r = reader.generateUFO(processRules=processRules) + results.append(r) + except: + if logger: + logger.exception("ufoProcessor error") + #results += reader.generateUFO(processRules=processRules) + reader = None + return results + +def getUFOVersion(ufoPath): + # + # + # + # + # creator + # org.robofab.ufoLib + # formatVersion + # 2 + # + # + metaInfoPath = os.path.join(ufoPath, u"metainfo.plist") + p = plistlib.readPlist(metaInfoPath) + return p.get('formatVersion') + +def swapGlyphNames(font, oldName, newName, swapNameExtension = "_______________swap"): + if not oldName in font or not newName in font: + return None + swapName = oldName + swapNameExtension + # park the old glyph + if not swapName in font: + font.newGlyph(swapName) + # swap the outlines + font[swapName].clear() + p = font[swapName].getPointPen() + font[oldName].drawPoints(p) + font[swapName].width = font[oldName].width + # lib? + + font[oldName].clear() + p = font[oldName].getPointPen() + font[newName].drawPoints(p) + font[oldName].width = font[newName].width + + font[newName].clear() + p = font[newName].getPointPen() + font[swapName].drawPoints(p) + font[newName].width = font[swapName].width + + # remap the components + for g in font: + for c in g.components: + if c.baseGlyph == oldName: + c.baseGlyph = swapName + continue + for g in font: + for c in g.components: + if c.baseGlyph == newName: + c.baseGlyph = oldName + continue + for g in font: + for c in g.components: + if c.baseGlyph == swapName: + c.baseGlyph = newName + + # change the names in groups + # the shapes will swap, that will invalidate the kerning + # so the names need to swap in the kerning as well. + newKerning = {} + for first, second in font.kerning.keys(): + value = font.kerning[(first,second)] + if first == oldName: + first = newName + elif first == newName: + first = oldName + if second == oldName: + second = newName + elif second == newName: + second = oldName + newKerning[(first, second)] = value + font.kerning.clear() + font.kerning.update(newKerning) + + for groupName, members in font.groups.items(): + newMembers = [] + for name in members: + if name == oldName: + newMembers.append(newName) + elif name == newName: + newMembers.append(oldName) + else: + newMembers.append(name) + font.groups[groupName] = newMembers + + remove = [] + for g in font: + if g.name.find(swapNameExtension)!=-1: + remove.append(g.name) + for r in remove: + del font[r] + +class DecomposePointPen(object): + + def __init__(self, glyphSet, outPointPen): + self._glyphSet = glyphSet + self._outPointPen = outPointPen + self.beginPath = outPointPen.beginPath + self.endPath = outPointPen.endPath + self.addPoint = outPointPen.addPoint + + def addComponent(self, baseGlyphName, transformation): + if baseGlyphName in self._glyphSet: + baseGlyph = self._glyphSet[baseGlyphName] + if transformation == _defaultTransformation: + baseGlyph.drawPoints(self) + else: + transformPointPen = TransformPointPen(self, transformation) + baseGlyph.drawPoints(transformPointPen) + + +class DesignSpaceProcessor(DesignSpaceDocument): + """ + builder of glyphs from designspaces + validate the data + if it works, make a generating thing + """ + + fontClass = defcon.Font + glyphClass = defcon.Glyph + libClass = defcon.Lib + glyphContourClass = defcon.Contour + glyphPointClass = defcon.Point + glyphComponentClass = defcon.Component + glyphAnchorClass = defcon.Anchor + kerningClass = defcon.Kerning + groupsClass = defcon.Groups + infoClass = defcon.Info + featuresClass = defcon.Features + + mathInfoClass = MathInfo + mathGlyphClass = MathGlyph + mathKerningClass = MathKerning + + def __init__(self, readerClass=None, writerClass=None, fontClass=None, ufoVersion=3): + super(DesignSpaceProcessor, self).__init__(readerClass=readerClass, writerClass=writerClass, fontClass=fontClass) + self.ufoVersion = ufoVersion # target UFO version + self.roundGeometry = False + self._glyphMutators = {} + self._infoMutator = None + self._kerningMutator = None + self._preppedAxes = None + self.fonts = {} + self._fontsLoaded = False + self.glyphNames = [] # list of all glyphnames + self.processRules = True + self.problems = [] # receptacle for problem notifications. Not big enough to break, but also not small enough to ignore. + + def generateUFO(self, processRules=True): + # makes the instances + # option to execute the rules + # make sure we're not trying to overwrite a newer UFO format + self.loadFonts() + self.checkDefault() + v = 0 + for instanceDescriptor in self.instances: + if instanceDescriptor.path is None: + continue + font = self.makeInstance(instanceDescriptor, processRules) + folder = os.path.dirname(instanceDescriptor.path) + path = instanceDescriptor.path + if not os.path.exists(folder): + os.makedirs(folder) + if os.path.exists(path): + existingUFOFormatVersion = getUFOVersion(path) + if existingUFOFormatVersion > self.ufoVersion: + self.problems.append(u"Can’t overwrite existing UFO%d with UFO%d."%(existingUFOFormatVersion, self.ufoVersion)) + continue + else: + font.save(path, self.ufoVersion) + self.problems.append("Generated %s as UFO%d"%(os.path.basename(path), self.ufoVersion)) + + def getInfoMutator(self): + """ Returns a info mutator """ + if self._infoMutator: + return self._infoMutator + infoItems = [] + for sourceDescriptor in self.sources: + loc = Location(sourceDescriptor.location) + sourceFont = self.fonts[sourceDescriptor.name] + infoItems.append((loc, self.mathInfoClass(sourceFont.info))) + bias, self._infoMutator = buildMutator(infoItems, axes=self._preppedAxes, bias=self.defaultLoc) + return self._infoMutator + + def getKerningMutator(self): + """ Return a kerning mutator """ + if self._kerningMutator: + return self._kerningMutator + kerningItems = [] + for sourceDescriptor in self.sources: + loc = Location(sourceDescriptor.location) + sourceFont = self.fonts[sourceDescriptor.name] + # this makes assumptions about the groups of all sources being the same. + kerningItems.append((loc, self.mathKerningClass(sourceFont.kerning, sourceFont.groups))) + bias, self._kerningMutator = buildMutator(kerningItems, axes=self._preppedAxes, bias=self.defaultLoc) + return self._kerningMutator + + def getGlyphMutator(self, glyphName, decomposeComponents=False): + """ Return a glyph mutator.defaultLoc + decomposeComponents = True causes the source glyphs to be decomposed first + before building the mutator. That gives you instances that do not depend + on a complete font. If you're calculating previews for instance. + """ + if glyphName in self._glyphMutators: + return self._glyphMutators[glyphName] + items = [] + for sourceDescriptor in self.sources: + loc = Location(sourceDescriptor.location) + f = self.fonts[sourceDescriptor.name] + if glyphName in sourceDescriptor.mutedGlyphNames: + continue + if not glyphName in f: + # log this> + continue + sourceGlyphObject = f[glyphName] + if decomposeComponents: + temp = self.glyphClass() + p = temp.getPointPen() + dpp = DecomposePointPen(f, p) + sourceGlyphObject.drawPoints(dpp) + temp.width = sourceGlyphObject.width + temp.name = sourceGlyphObject.name + #temp.lib = sourceGlyphObject.lib + processThis = temp + else: + processThis = sourceGlyphObject + items.append((loc, self.mathGlyphClass(processThis))) + bias, self._glyphMutators[glyphName] = buildMutator(items, axes=self._preppedAxes, bias=self.defaultLoc) + return self._glyphMutators[glyphName] + + + def loadFonts(self, reload=False): + # Load the fonts and find the default candidate based on the info flag + if self._fontsLoaded and not reload: + return + names = set() + for sourceDescriptor in self.sources: + if not sourceDescriptor.name in self.fonts: + self.fonts[sourceDescriptor.name] = self._instantiateFont(sourceDescriptor.path) + self.problems.append("loaded master from %s, format %d"%(sourceDescriptor.path, getUFOVersion(sourceDescriptor.path))) + names = names | set(self.fonts[sourceDescriptor.name].keys()) + self.glyphNames = list(names) + self._fontsLoaded = True + + def makeInstance(self, instanceDescriptor, doRules=False, glyphNames=None): + """ Generate a font object for this instance """ + font = self._instantiateFont(None) + self._preppedAxes = self._prepAxesForBender() + # make fonty things here + loc = Location(instanceDescriptor.location) + # groups, + if hasattr(self.fonts[self.default.name], "kerningGroupConversionRenameMaps"): + renameMap = self.fonts[self.default.name].kerningGroupConversionRenameMaps + self.problems.append("renameMap %s"%renameMap) + else: + renameMap = {} + font.kerningGroupConversionRenameMaps = renameMap + # make the kerning + if instanceDescriptor.kerning: + try: + self.getKerningMutator().makeInstance(loc).extractKerning(font) + except: + self.problems.append("Could not make kerning for %s"%loc) + # make the info + if instanceDescriptor.info: + try: + self.getInfoMutator().makeInstance(loc).extractInfo(font.info) + info = self._infoMutator.makeInstance(loc) + info.extractInfo(font.info) + font.info.familyName = instanceDescriptor.familyName + font.info.styleName = instanceDescriptor.styleName + font.info.postScriptFontName = instanceDescriptor.postScriptFontName + font.info.styleMapFamilyName = instanceDescriptor.styleMapFamilyName + font.info.styleMapStyleName = instanceDescriptor.styleMapStyleName + # localised names need to go to the right openTypeNameRecords + # records = [] + # nameID = 1 + # platformID = + # for languageCode, name in instanceDescriptor.localisedStyleMapFamilyName.items(): + # # Name ID 1 (font family name) is found at the generic styleMapFamily attribute. + # records.append((nameID, )) + + except: + self.problems.append("Could not make fontinfo for %s"%loc) + # copied info + for sourceDescriptor in self.sources: + if sourceDescriptor.copyInfo: + # this is the source + self._copyFontInfo(self.fonts[sourceDescriptor.name].info, font.info) + if sourceDescriptor.copyLib: + # excplicitly copy the font.lib items + for key, value in self.fonts[sourceDescriptor.name].lib.items(): + font.lib[key] = value + if sourceDescriptor.copyFeatures: + featuresText = self.fonts[sourceDescriptor.name].features.text + if isinstance(featuresText, str): + font.features.text = u""+featuresText + elif isinstance(featuresText, unicode): + font.features.text = featuresText + # glyphs + if glyphNames: + selectedGlyphNames = glyphNames + else: + selectedGlyphNames = self.glyphNames + # add the glyphnames to the font.lib['public.glyphOrder'] + if not 'public.glyphOrder' in font.lib.keys(): + font.lib['public.glyphOrder'] = selectedGlyphNames + for glyphName in selectedGlyphNames: + try: + glyphMutator = self.getGlyphMutator(glyphName) + except: + self.problems.append("Could not make mutator for glyph %s"%glyphName) + continue + if glyphName in instanceDescriptor.glyphs.keys(): + # reminder: this is what the glyphData can look like + # {'instanceLocation': {'custom': 0.0, 'weight': 824.0}, + # 'masters': [{'font': 'master.Adobe VF Prototype.Master_0.0', + # 'glyphName': 'dollar.nostroke', + # 'location': {'custom': 0.0, 'weight': 0.0}}, + # {'font': 'master.Adobe VF Prototype.Master_1.1', + # 'glyphName': 'dollar.nostroke', + # 'location': {'custom': 0.0, 'weight': 368.0}}, + # {'font': 'master.Adobe VF Prototype.Master_2.2', + # 'glyphName': 'dollar.nostroke', + # 'location': {'custom': 0.0, 'weight': 1000.0}}, + # {'font': 'master.Adobe VF Prototype.Master_3.3', + # 'glyphName': 'dollar.nostroke', + # 'location': {'custom': 100.0, 'weight': 1000.0}}, + # {'font': 'master.Adobe VF Prototype.Master_0.4', + # 'glyphName': 'dollar.nostroke', + # 'location': {'custom': 100.0, 'weight': 0.0}}, + # {'font': 'master.Adobe VF Prototype.Master_4.5', + # 'glyphName': 'dollar.nostroke', + # 'location': {'custom': 100.0, 'weight': 368.0}}], + # 'unicodes': [36]} + glyphData = instanceDescriptor.glyphs[glyphName] + else: + glyphData = {} + font.newGlyph(glyphName) + font[glyphName].clear() + if glyphData.get('mute', False): + # mute this glyph, skip + continue + glyphInstanceLocation = Location(glyphData.get("instanceLocation", instanceDescriptor.location)) + try: + uniValues = glyphMutator[()][0].unicodes + except IndexError: + uniValues = [] + glyphInstanceUnicodes = glyphData.get("unicodes", uniValues) + note = glyphData.get("note") + if note: + font[glyphName] = note + masters = glyphData.get("masters", None) + if masters: + items = [] + for glyphMaster in masters: + sourceGlyphFont = glyphMaster.get("font") + sourceGlyphName = glyphMaster.get("glyphName", glyphName) + m = self.fonts.get(sourceGlyphFont) + if not sourceGlyphName in m: + continue + sourceGlyph = MathGlyph(m[sourceGlyphName]) + sourceGlyphLocation = Location(glyphMaster.get("location")) + items.append((sourceGlyphLocation, sourceGlyph)) + bias, glyphMutator = buildMutator(items, axes=self._preppedAxes, bias=self.defaultLoc) + try: + glyphInstanceObject = glyphMutator.makeInstance(glyphInstanceLocation) + except IndexError: + # alignment problem with the data? + print("Error making instance %s"%glyphName) + continue + font.newGlyph(glyphName) + font[glyphName].clear() + if self.roundGeometry: + try: + glyphInstanceObject = glyphInstanceObject.round() + except AttributeError: + pass + try: + glyphInstanceObject.extractGlyph(font[glyphName], onlyGeometry=True) + except TypeError: + # this causes ruled glyphs to end up in the wrong glyphname + # but defcon2 objects don't support it + pPen = font[glyphName].getPointPen() + font[glyphName].clear() + glyphInstanceObject.drawPoints(pPen) + font[glyphName].width = glyphInstanceObject.width + font[glyphName].unicodes = glyphInstanceUnicodes + if doRules: + resultNames = processRules(self.rules, loc, self.glyphNames) + for oldName, newName in zip(self.glyphNames, resultNames): + if oldName != newName: + swapGlyphNames(font, oldName, newName) + # copy the glyph lib? + #for sourceDescriptor in self.sources: + # if sourceDescriptor.copyLib: + # pass + # pass + # store designspace location in the font.lib + font.lib['designspace'] = list(instanceDescriptor.location.items()) + return font + + def _instantiateFont(self, path): + """ Return a instance of a font object with all the given subclasses""" + return self.fontClass(path, + libClass=self.libClass, + kerningClass=self.kerningClass, + groupsClass=self.groupsClass, + infoClass=self.infoClass, + featuresClass=self.featuresClass, + glyphClass=self.glyphClass, + glyphContourClass=self.glyphContourClass, + glyphPointClass=self.glyphPointClass, + glyphComponentClass=self.glyphComponentClass, + glyphAnchorClass=self.glyphAnchorClass) + + def _copyFontInfo(self, sourceInfo, targetInfo): + """ Copy the non-calculating fields from the source info.""" + infoAttributes = [ + "versionMajor", + "versionMinor", + "copyright", + "trademark", + "note", + "openTypeGaspRangeRecords", + "openTypeHeadCreated", + "openTypeHeadFlags", + "openTypeNameDesigner", + "openTypeNameDesignerURL", + "openTypeNameManufacturer", + "openTypeNameManufacturerURL", + "openTypeNameLicense", + "openTypeNameLicenseURL", + "openTypeNameVersion", + "openTypeNameUniqueID", + "openTypeNameDescription", + "#openTypeNamePreferredFamilyName", + "#openTypeNamePreferredSubfamilyName", + "#openTypeNameCompatibleFullName", + "openTypeNameSampleText", + "openTypeNameWWSFamilyName", + "openTypeNameWWSSubfamilyName", + "openTypeNameRecords", + "openTypeOS2Selection", + "openTypeOS2VendorID", + "openTypeOS2Panose", + "openTypeOS2FamilyClass", + "openTypeOS2UnicodeRanges", + "openTypeOS2CodePageRanges", + "openTypeOS2Type", + "postscriptIsFixedPitch", + "postscriptForceBold", + "postscriptDefaultCharacter", + "postscriptWindowsCharacterSet" + ] + for infoAttribute in infoAttributes: + copy = False + if self.ufoVersion == 1 and infoAttribute in fontInfoAttributesVersion1: + copy = True + elif self.ufoVersion == 2 and infoAttribute in fontInfoAttributesVersion2: + copy = True + elif self.ufoVersion == 3 and infoAttribute in fontInfoAttributesVersion3: + copy = True + if copy: + value = getattr(sourceInfo, infoAttribute) + setattr(targetInfo, infoAttribute, value) + + + + + +if __name__ == "__main__": + # standalone test + import shutil + import os + from defcon.objects.font import Font + import logging + + def addGlyphs(font, s): + # we need to add the glyphs + step = 0 + for n in ['glyphOne', 'glyphTwo', 'glyphThree', 'glyphFour']: + font.newGlyph(n) + g = font[n] + p = g.getPen() + p.moveTo((0,0)) + p.lineTo((s,0)) + p.lineTo((s,s)) + p.lineTo((0,s)) + p.closePath() + g.move((0,s+step)) + g.width = s + step += 50 + for n, w in [('wide', 800), ('narrow', 100)]: + font.newGlyph(n) + g = font[n] + p = g.getPen() + p.moveTo((0,0)) + p.lineTo((w,0)) + p.lineTo((w,font.info.ascender)) + p.lineTo((0,font.info.ascender)) + p.closePath() + g.width = w + font.newGlyph("wide.component") + g = font["wide.component"] + comp = g.instantiateComponent() + comp.baseGlyph = "wide" + comp.offset = (0,0) + g.appendComponent(comp) + g.width = font['wide'].width + font.newGlyph("narrow.component") + g = font["narrow.component"] + comp = g.instantiateComponent() + comp.baseGlyph = "narrow" + comp.offset = (0,0) + g.appendComponent(comp) + g.width = font['narrow'].width + uniValue = 200 + for g in font: + g.unicode = uniValue + uniValue += 1 + + def fillInfo(font): + font.info.unitsPerEm = 1000 + font.info.ascender = 800 + font.info.descender = -200 + + def makeTestFonts(rootPath): + """ Make some test fonts that have the kerning problem.""" + path1 = os.path.join(rootPath, "geometryMaster1.ufo") + path2 = os.path.join(rootPath, "geometryMaster2.ufo") + path3 = os.path.join(rootPath, "my_test_instance_dir_one", "geometryInstance%3.3f.ufo") + path4 = os.path.join(rootPath, "my_test_instance_dir_two", "geometryInstanceAnisotropic1.ufo") + path5 = os.path.join(rootPath, "my_test_instance_dir_two", "geometryInstanceAnisotropic2.ufo") + f1 = Font() + fillInfo(f1) + addGlyphs(f1, 100) + f1.features.text = u"# features text from master 1" + f2 = Font() + fillInfo(f2) + addGlyphs(f2, 500) + f2.features.text = u"# features text from master 2" + f1.info.ascender = 400 + f1.info.descender = -200 + f2.info.ascender = 600 + f2.info.descender = -100 + f1.info.copyright = u"This is the copyright notice from master 1" + f2.info.copyright = u"This is the copyright notice from master 2" + f1.lib['ufoProcessor.test.lib.entry'] = "Lib entry for master 1" + f2.lib['ufoProcessor.test.lib.entry'] = "Lib entry for master 2" + f1.save(path1, 2) + f2.save(path2, 2) + return path1, path2, path3, path4, path5 + + def makeSwapFonts(rootPath): + """ Make some test fonts that have the kerning problem.""" + path1 = os.path.join(rootPath, "Swap.ufo") + path2 = os.path.join(rootPath, "Swapped.ufo") + f1 = Font() + fillInfo(f1) + addGlyphs(f1, 100) + f1.features.text = u"# features text from master 1" + f1.info.ascender = 800 + f1.info.descender = -200 + f1.kerning[('glyphOne', 'glyphOne')] = -10 + f1.kerning[('glyphTwo', 'glyphTwo')] = 10 + f1.save(path1, 2) + return path1, path2 + + def testDocument(docPath): + # make the test fonts and a test document + testFontPath = os.path.join(os.getcwd(), "automatic_testfonts") + m1, m2, i1, i2, i3 = makeTestFonts(testFontPath) + d = DesignSpaceProcessor() + a = AxisDescriptor() + a.name = "pop" + a.minimum = 50 + a.maximum = 1000 + a.default = 0 + a.tag = "pop*" + d.addAxis(a) + s1 = SourceDescriptor() + s1.path = m1 + s1.location = dict(pop=a.minimum) + s1.name = "test.master.1" + s1.copyInfo = True + s1.copyFeatures = True + s1.copyLib = True + d.addSource(s1) + s2 = SourceDescriptor() + s2.path = m2 + s2.location = dict(pop=1000) + s2.name = "test.master.2" + #s2.copyInfo = True + d.addSource(s2) + for counter in range(3): + factor = counter / 2 + i = InstanceDescriptor() + v = a.minimum+factor*(a.maximum-a.minimum) + i.path = i1%v + i.familyName = "TestFamily" + i.styleName = "TestStyle_pop%3.3f"%(v) + i.name = "%s-%s"%(i.familyName, i.styleName) + i.location = dict(pop=v) + i.info = True + i.kerning = True + if counter == 2: + i.glyphs['glyphTwo'] = dict(name="glyphTwo", mute=True) + i.copyLib = True + if counter == 2: + i.glyphs['narrow'] = dict(instanceLocation=dict(pop=400), unicodes=[0x123, 0x124, 0x125]) + d.addInstance(i) + d.write(docPath) + + def testGenerateInstances(docPath): + # execute the test document + d = DesignSpaceProcessor() + d.read(docPath) + d.generateUFO() + if d.problems: + print(d.problems) + + def testSwap(docPath): + srcPath, dstPath = makeSwapFonts(os.path.dirname(docPath)) + + f = Font(srcPath) + swapGlyphNames(f, "narrow", "wide") + f.info.styleName = "Swapped" + f.save(dstPath) + + # test the results in newly opened fonts + old = Font(srcPath) + new = Font(dstPath) + assert new.kerning.get(("narrow", "narrow")) == old.kerning.get(("wide","wide")) + assert new.kerning.get(("wide", "wide")) == old.kerning.get(("narrow","narrow")) + # after the swap these widths should be the same + assert old['narrow'].width == new['wide'].width + assert old['wide'].width == new['narrow'].width + # The following test may be a bit counterintuitive: + # the rule swaps the glyphs, but we do not want glyphs that are not + # specifically affected by the rule to *appear* any different. + # So, components have to be remapped. + assert new['wide.component'].components[0].baseGlyph == "narrow" + assert new['narrow.component'].components[0].baseGlyph == "wide" + + def testUnicodes(docPath): + # after executing testSwap there should be some test fonts + # let's check if the unicode values for glyph "narrow" arrive at the right place. + d = DesignSpaceProcessor() + d.read(docPath) + for instance in d.instances: + if os.path.exists(instance.path): + f = Font(instance.path) + if instance.name == "TestFamily-TestStyle_pop1000.000": + assert f['narrow'].unicodes == [291, 292, 293] + else: + assert f['narrow'].unicodes == [207] + else: + print("Missing test font at %s"%instance.path) + + selfTest = True + if selfTest: + testRoot = os.path.join(os.getcwd(), "automatic_testfonts") + if os.path.exists(testRoot): + shutil.rmtree(testRoot) + docPath = os.path.join(testRoot, "automatic_test.designspace") + testDocument(docPath) + testGenerateInstances(docPath) + testSwap(docPath) + testUnicodes(docPath) diff --git a/README.md b/README.md new file mode 100644 index 000000000..aedd41aa5 --- /dev/null +++ b/README.md @@ -0,0 +1,591 @@ +MutatorMath started out with its own reader and writer for designspaces. Since then the use of designspace has broadened and it would be useful to have a reader and writer that are independent of a specific system. + +DesignSpaceDocument +=================== + +An object to read, write and edit interpolation systems for typefaces. + +* the format was originally written for MutatorMath. +* the format is now also used in fontTools.varlib. +* Define sources, axes and instances. +* Not all values might be required by all applications. + +A couple of differences between things that use designspaces: + +* Varlib does not support anisotropic interpolations. +* MutatorMath and Superpolator will extrapolate over the boundaries of the axes. Varlib can not. +* Varlib requires much less data to define an instance than MutatorMath. +* The goals of Varlib and MutatorMath are different, so not all attributes are always needed. +* Need to expand the description of FDK use of designspace files. + +The DesignSpaceDocument object can read and write `.designspace` data. It imports the axes, sources and instances to very basic **descriptor** objects that store the data in attributes. Data is added to the document by creating such descriptor objects, filling them with data and then adding them to the document. This makes it easy to integrate this object in different contexts. + +The **DesignSpaceDocument** object can be subclassed to work with different objects, as long as they have the same attributes. + +```python +from designSpaceDocument import DesignSpaceDocument +doc = DesignSpaceDocument() +doc.read("some/path/to/my.designspace") +doc.axes +doc.sources +doc.instances +``` + +# Validation +Some validation is done when reading. + +### Axes +* If the `axes` element is available in the document then all locations will check their dimensions against the defined axes. If a location uses an axis that is not defined it will be ignored. +* If there are no `axes` in the document, locations will accept all axis names, so that we can.. +* Use `doc.checkAxes()` to reconstruct axes definitions based on the `source.location` values. If you save the document the axes will be there. + +### Default font +* The source with the `copyInfo` flag indicates this is the default font. +* In mutatorMath the default font is selected automatically. A warning is printed if the mutatorMath default selection differs from the one set by `copyInfo`. But the `copyInfo` source will be used. +* If no source has a `copyInfo` flag, mutatorMath will be used to select one. This source gets its `copyInfo` flag set. If you save the document this flag will be set. +* Use `doc.checkDefault()` to set the default font. + +# Localisation + +Some of the descriptors support localised names. The names are stored in dictionaries using the language code as key. That means that there are now two places to store names: the old attribute and the new localised dictionary, `obj.stylename` and `obj.localisedStyleName['en']`. + +# Rules +**The `rule` element is experimental.** Some ideas behind how rules could work in designspaces come from Superpolator. Such rules can maybe be used to describe some of the conditional GSUB functionality of OpenType 1.8. The definition of a rule is not that complicated. A rule has a name, and it has a number of conditions. The rule also contains a list of glyphname pairs: the glyphs that need to be substituted. + +### Variable font instances +* In an variable font the substitution happens at run time: there are no changes in the font, only in the sequence of glyphnames that is rendered. +* The infrastructure to get this rule data in a variable font needs to be built. + +### UFO instances +* When making instances as UFOs however, we need to swap the glyphs so that the original shape is still available. For instance, if a rule swaps `a` for `a.alt`, but a glyph that references `a` in a component would then show the new `a.alt`. +* But that can lead to unexpected results. So, if there are no rules for `adieresis` (assuming it references `a`) then that glyph **should not change appearance**. That means that when the rule swaps `a` and `a.alt` it also swaps all components that reference these glyphs so they keep their appearance. +* The swap function also needs to take care of swapping the names in kerning data. + +## SourceDescriptor object +### Attributes +* `filename`: string. A relative path to the source file, **as it is in the document**. MutatorMath + Varlib. +* `path`: string. Absolute path to the source file, calculated from the document path and the string in the filename attr. MutatorMath + Varlib. +* `name`: string. Optional. Unique identifier name for this source, if there is one or more `instance.glyph` elements in the document. MutatorMath. +* `location`: dict. Axis values for this source. MutatorMath + Varlib +* `copyLib`: bool. Indicates if the contents of the font.lib need to be copied to the instances. MutatorMath. +* `copyInfo` bool. Indicates if the non-interpolating font.info needs to be copied to the instances. Also indicates this source is expected to be the default font. MutatorMath + Varlib +* `copyGroups` bool. Indicates if the groups need to be copied to the instances. MutatorMath. +* `copyFeatures` bool. Indicates if the feature text needs to be copied to the instances. MutatorMath. +* `muteKerning`: bool. Indicates if the kerning data from this source needs to be muted (i.e. not be part of the calculations). MutatorMath. +* `muteInfo`: bool. Indicated if the interpolating font.info data for this source needs to be muted. MutatorMath. +* `mutedGlyphNames`: list. Glyphnames that need to be muted in the instances. MutatorMath. +* `familyName`: string. Family name of this source. Though this data can be extracted from the font, it can be efficient to have it right here. Varlib. +* `styleName`: string. Style name of this source. Though this data can be extracted from the font, it can be efficient to have it right here. Varlib. + +```python +doc = DesignSpaceDocument() +s1 = SourceDescriptor() +s1.path = masterPath1 +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) +``` + +## InstanceDescriptor object + +### Attributes +* `filename`: string. Relative path to the instance file, **as it is in the document**. The file may or may not exist. MutatorMath. +* `path`: string. Absolute path to the source file, calculated from the document path and the string in the filename attr. The file may or may not exist. MutatorMath. +* `name`: string. Unique identifier name of the instance, used to identify it if it needs to be referenced from elsewhere in the document. +* `location`: dict. Axis values for this source. MutatorMath + Varlib. +* `familyName`: string. Family name of this instance. MutatorMath + Varlib. +* `localisedFamilyName`: dict. A dictionary of localised family name strings, keyed by language code. +* `styleName`: string. Style name of this source. MutatorMath + Varlib. +* `localisedStyleName`: dict. A dictionary of localised stylename strings, keyed by language code. +* `postScriptFontName`: string. Postscript fontname for this instance. MutatorMath. +* `styleMapFamilyName`: string. StyleMap familyname for this instance. MutatorMath. +* `localisedStyleMapFamilyName`: A dictionary of localised style map familyname strings, keyed by language code. +* `localisedStyleMapStyleName`: A dictionary of localised style map stylename strings, keyed by language code. +* `styleMapStyleName`: string. StyleMap stylename for this instance. MutatorMath. +* `glyphs`: dict for special master definitions for glyphs. If glyphs need special masters (to record the results of executed rules for example). MutatorMath. +* `mutedGlyphNames`: list of glyphnames that should be suppressed in the generation of this instance. +* `kerning`: bool. Indicates if this instance needs its kerning calculated. MutatorMath. +* `info`: bool. Indicated if this instance needs the interpolating font.info calculated. + +### Methods + +These methods give easier access to the localised names. + +* `setStyleName(styleName, languageCode="en")` +* `getStyleName(languageCode="en")` +* `setFamilyName(familyName, languageCode="en")` +* `getFamilyName(self, languageCode="en")` +* `setStyleMapStyleName(styleMapStyleName, languageCode="en")` +* `getStyleMapStyleName(languageCode="en")` +* `setStyleMapFamilyName(styleMapFamilyName, languageCode="en")` +* `getStyleMapFamilyName(languageCode="en")` + +### Example + +```python +i2 = InstanceDescriptor() +i2.path = instancePath2 +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", unicodeValue=1234) +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) +``` +## AxisDescriptor object +* `tag`: string. Four letter tag for this axis. Some might be registered at the [OpenType specification](https://www.microsoft.com/typography/otspec/fvar.htm#VAT). Privately-defined axis tags must begin with an uppercase letter and use only uppercase letters or digits. +* `name`: string. Name of the axis as it is used in the location dicts. MutatorMath + Varlib. +* `labelNames`: dict. When defining a non-registered axis, it will be necessary to define user-facing readable names for the axis. Keyed by xml:lang code. Varlib. +* `minimum`: number. The minimum value for this axis. MutatorMath + Varlib. +* `maximum`: number. The maximum value for this axis. MutatorMath + Varlib. +* `default`: number. The default value for this axis, i.e. when a new location is created, this is the value this axis will get. MutatorMath + Varlib. +* `map`: list of input / output values that can describe a warp of user space to designspace coordinates. If no map values are present, it is assumed it is [(minimum, minimum), (maximum, maximum)]. Varlib. + +```python +a1 = AxisDescriptor() +a1.minimum = 1 +a1.maximum = 1000 +a1.default = 400 +a1.name = "weight" +a1.tag = "wght" +a1.labelNames[u'fa-IR'] = u"قطر" +a1.labelNames[u'en'] = u"Wéíght" +a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] +``` +## RuleDescriptor object +* `name`: string. Unique name for this rule. Will be used to reference this rule data. +* `conditions`: list of dicts with condition data. +* Each condition specifies the axis name it is active on and the values between which the condition is true. + +```python +r1 = RuleDescriptor() +r1.name = "unique.rule.name" +r1.conditions.append(dict(name="weight", minimum=-10, maximum=10)) +r1.conditions.append(dict(name="width", minimum=-10, maximum=10)) +``` + +# Subclassing descriptors + +The DesignSpaceDocument can take subclassed Reader and Writer objects. This allows you to work with your own descriptors. You could subclass the descriptors. But as long as they have the basic attributes the descriptor does not need to be a subclass. + +```python +class MyDocReader(BaseDocReader): + ruleDescriptorClass = MyRuleDescriptor + axisDescriptorClass = MyAxisDescriptor + sourceDescriptorClass = MySourceDescriptor + instanceDescriptorClass = MyInstanceDescriptor + +class MyDocWriter(BaseDocWriter): + ruleDescriptorClass = MyRuleDescriptor + axisDescriptorClass = MyAxisDescriptor + sourceDescriptorClass = MySourceDescriptor + instanceDescriptorClass = MyInstanceDescriptor + +myDoc = DesignSpaceDocument(KeyedDocReader, KeyedDocWriter) +``` + +# Document xml structure + +* The `axes` element contains one or more `axis` elements. +* The `sources` element contains one or more `source` elements. +* The `instances` element contains one or more `instance` elements. + +```xml + + + + + + + + + + + + + + + +``` +# 1. axis element +* Define a single axis +* Child element of `axes` + +### Attributes +* `name`: required, string. Name of the axis that is used in the location elements. +* `tag`: required, string, 4 letters. Some axis tags are registered in the OpenType Specification. +* `minimum`: required, number. The minimum value for this axis. +* `maximum`: required, number. The maximum value for this axis. +* `default`: required, number. The default value for this axis. +* `hidden`: optional, 0 or 1. Records whether this axis needs to be hidden in interfaces. + +```xml + +``` + +# 1.1 labelname element +* Defines a human readable name for UI use. +* Optional for non-registered axis names. +* Can be localised with `xml:lang` +* Child element of `axis` + +### Attributes +* `xml:lang`: required, string. [XML language definition](https://www.w3.org/International/questions/qa-when-xmllang.en) + +### Value +* The natural language name of this axis. + +### Example +```xml +قطر +Wéíght +``` + +# 1.2 map element +* Defines a single node in a series of input value / output value pairs. +* Together these values transform the designspace. +* Child of `axis` element. + +### Example +```xml + + + +``` + +### Example of all axis elements together: +```xml + + + قطر + Wéíght + + + + + + + +``` + +# 2. location element +* Defines a coordinate in the design space. +* Dictionary of axisname: axisvalue +* Used in `source`, `instance` and `glyph` elements. + +# 2.1 dimension element +* Child element of `location` + +### Attributes +* `name`: required, string. Name of the axis. +* `xvalue`: required, number. The value on this axis. +* `yvalue`: optional, number. Separate value for anisotropic interpolations. + +### Example +```xml + + + + +``` + +# 3. source element +* Defines a single font that contributes to the designspace. +* Child element of `sources` + +### Attributes +* `familyname`: optional, string. The family name of the source font. While this could be extracted from the font data itself, it can be more efficient to add it here. +* `stylename`: optional, string. The style name of the source font. +* `name`: required, string. A unique name that can be used to identify this font if it needs to be referenced elsewhere. +* `filename`: required, string. A path to the source file, relative to the root path of this document. The path can be at the same level as the document or lower. + +# 3.1 lib element +* `` +* Child element of `source` +* Defines if the instances can inherit the data in the lib of this source. +* MutatorMath only + +# 3.2 info element +* `` +* Child element of `source` +* Defines if the instances can inherit the non-interpolating font info from this source. +* MutatorMath + Varlib +* NOTE: **This presence of this element indicates this source is to be the default font.** + +# 3.3 features element +* `` +* Defines if the instances can inherit opentype feature text from this source. +* Child element of `source` +* MutatorMath only + +# 3.4 glyph element +* Can appear in `source` as well as in `instance` elements. +* In a `source` element this states if a glyph is to be excluded from the calculation. +* MutatorMath only + +### Attributes +* `mute`: optional attribute, number 1 or 0. Indicate if this glyph should be ignored as a master. +* `` +* MutatorMath only + +# 3.5 kerning element +* `` +* Can appear in `source` as well as in `instance` elements. + +### Attributes +* `mute`: required attribute, number 1 or 0. Indicate if the kerning data from this source is to be excluded from the calculation. +* If the kerning element is not present, assume `mute=0`, yes, include the kerning of this source in the calculation. +* MutatorMath only + + +### Example +```xml + + + + + + + + + + + +``` +# 4. instance element + +* Defines a single font that can be calculated with the designspace. +* Child element of `instances` +* For use in Varlib the instance element really only needs the names and the location. The `glyphs` element is not required. +* MutatorMath uses the `glyphs` element to describe how certain glyphs need different masters, mainly to describe the effects of conditional rules in Superpolator. + +### Attributes +* `familyname`: required, string. The family name of the instance font. Corresponds with `font.info.familyName` +* `stylename`: required, string. The style name of the instance font. Corresponds with `font.info.styleName` +* `name`: required, string. A unique name that can be used to identify this font if it needs to be referenced elsewhere. +* `filename`: string. Required for MutatorMath. A path to the instance file, relative to the root path of this document. The path can be at the same level as the document or lower. +* `postscriptfontname`: string. Optional for MutatorMath. Corresponds with `font.info.postscriptFontName` +* `stylemapfamilyname`: string. Optional for MutatorMath. Corresponds with `styleMapFamilyName` +* `stylemapstylename `: string. Optional for MutatorMath. Corresponds with `styleMapStyleName` + +### Example for varlib +```xml + + + + + + + + +``` + +# 4.1 glyphs element +* Container for `glyph` elements. +* Optional +* MutatorMath only. + +# 4.2 glyph element +* Child element of `glyphs` +* May contain a `location` element. + +### Attributes +* `name`: string. The name of the glyph. +* `unicode`: string. Unicode values for this glyph, in hexadecimal. Multiple values should be separated with a space. +* `mute`: optional attribute, number 1 or 0. Indicate if this glyph should be supressed in the output. + +# 4.2.1 note element +* String. The value corresponds to glyph.note in UFO. + +# 4.2.2 masters element +* Container for `master` elements +* These `master` elements define an alternative set of glyph masters for this glyph. + +# 4.2.2.1 master element +* Defines a single alternative master for this glyph. + +#4.3 Localised names for intances +Localised names for instances can be included with these simple elements with an xml:lang attribute: [XML language definition](https://www.w3.org/International/questions/qa-when-xmllang.en) + + +* stylename +* familyname +* stylemapstylename +* stylemapfamilyname + +### Example +```xml +Demigras +半ば +Montserrat +モンセラート +Standard +Montserrat Halbfett +モンセラート SemiBold +``` + +### Attributes +* `glyphname`: the name of the alternate master glyph. +* `source`: the identifier name of the source this master glyph needs to be loaded from + +### Example +```xml + + + + + + + + + + + + + A note about this glyph + + + + + + + + + + + + + +``` + +# 5.0 rules element + * Container for `rule` elements + +# 5.1 rule element + * Defines a named rule with a set of conditions. +* The conditional substitutions specifed in the OpenType specification can be much more elaborate than what it recorded in this element. +* So while authoring tools are welcome to use the `sub` element, they're intended as preview / example / test substitutions for the rule. + +### Attributes +* `name`: required, string. A unique name that can be used to identify this rule if it needs to be referenced elsewhere. + +# 5.1.1 condition element +* Child element of `rule` +* Between the `minimum` and `maximum` this rule is `true`. +* If `minimum` is not available, assume it is `axis.minimum`. +* If `maximum` is not available, assume it is `axis.maximum`. +* One or the other or both need to be present. + +### Attributes +* `name`: string, required. Must match one of the defined `axis` name attributes. +* `minimum`: number, required*. The low value. +* `maximum`: number, required*. The high value. + +# 5.1.2 sub element +* Child element of `rule`. +* Defines which glyphs to replace when the rule is true. +* This element is optional. It may be useful for editors to know which glyphs can be used to preview the axis. + +### Attributes +* `name`: string, required. The name of the glyph this rule looks for. +* `byname`: string, required. The name of the glyph it is replaced with. + +### Example +```xml + + + + + + + +``` + +# 6 Notes + +## Paths and filenames +A designspace file needs to store many references to UFO files. + +* designspace files can be part of versioning systems and appear on different computers. This means it is not possible to store absolute paths. +* So, all paths are relative to the designspace document path. +* Using relative paths allows designspace files and UFO files to be **near** each other, and that they can be **found** without enforcing one particular structure. +* The **filename** attribute in the `SourceDescriptor` and `InstanceDescriptor` classes stores the preferred relative path. +* The **path** attribute in these objects stores the absolute path. It is calculated from the document path and the relative path in the filename attribute when the object is created. +* Only the **filename** attribute is written to file. +* Both **filename** and **path** must use forward slashes (`/`) as path separators, even on Windows. + +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. Before writing to file, the `documentObject.updatePaths()` method prepares the paths as follows: + +**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. The difference could have happened for any number of reasons. Assuming the values were not in conflict when the object was created, either could have changed. We can't guess. +* Assume the path attribute is more up to date. Calculate a new value for filename based on the path and the document path. + +## Recommendation for editors +* If you want to explicitly set the **filename** attribute, leave the path attribute empty. +* If you want to explicitly set the **path** attribute, leave the filename attribute empty. It will be recalculated. +* Use `documentObject.updateFilenameFromPath()` to explicitly set the **filename** attributes for all instance and source descriptors. + +# 7 This document + +* The package is rather new and changes are to be expected. diff --git a/scripting.md b/scripting.md new file mode 100644 index 000000000..5160b4815 --- /dev/null +++ b/scripting.md @@ -0,0 +1,183 @@ +# Scripting a designspace + +It can be useful to build a designspace with a script rather than construct one with an interface like [Superpolator](http://superpolator.com) or [DesignSpaceEditor](https://github.com/LettError/designSpaceRoboFontExtension). The [designSpaceDocument](https://github.com/LettError/designSpaceDocument) offers a some tools for building designspaces in Python. This document shows an example. + +So, suppose you installed the [designSpaceDocument](https://github.com/LettError/designSpaceDocument) package through your favorite `git` client. + +The `DesignSpaceDocument` object represents the document, whether it already exists or not. Make a new one: + +```python +from designSpaceDocument import DesignSpaceDocument, AxisDescriptor, SourceDescriptor, InstanceDescriptor +doc = DesignSpaceDocument() +``` + +We want to create definitions for axes, sources and instances. That means there are a lot of attributes to set. The **DesignSpaceDocument object** uses objects to describe the axes, sources and instances. These are relatively simple objects, think of these as collections of attributes. + +* [Attributes of the Source descriptor](https://github.com/LettError/designSpaceDocument#source-descriptor-object-attributes) +* [Attributes of the Instance descriptor](https://github.com/LettError/designSpaceDocument#instance-descriptor-object) +* [Attributes of the Axis descriptor](https://github.com/LettError/designSpaceDocument#axis-descriptor-object) +* Read about [subclassing descriptors](https://github.com/LettError/designSpaceDocument#subclassing-descriptors) + +## Make an axis object + +Make a descriptor object and add it to the document. + +```python +a1 = AxisDescriptor() +a1.maximum = 1000 +a1.minimum = 0 +a1.default = 0 +a1.name = "weight" +a1.tag = "wght" +doc.addAxis(a1) +``` +* You can add as many axes as you need. OpenType has a maximum of around 64K. DesignSpaceEditor has a maximum of 5. +* The `name` attribute is the name you'll be using as the axis name in the locations. +* The `tag` attribute is the one of the registered [OpenType Variation Axis Tags](https://www.microsoft.com/typography/otspec/fvar.htm#VAT) + +### Option: add label names + +The **labelnames** attribute is intended to store localisable, human readable names for this axis if this is not an axis that is registered by OpenType. Think "The label next to the slider". The attribute is a dictionary. The key is the [xml language tag](https://www.w3.org/International/articles/language-tags/), the value is a utf-8 string with the name. Whether or not this attribute is used depends on the font building tool, the operating system and the authoring software. This, at least, is the place to record it. + +```python +a1.labelNames['fa-IR'] = u"قطر" +a1.labelNames['en'] = u"Wéíght" +``` + +### Option: add a map + +The **map** attribute is a list of (input, output) mapping values intended for [axis variations table of OpenType](https://www.microsoft.com/typography/otspec/avar.htm). + +```python +a1.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)] +``` + + +## Make a source object + +A **source** is an object that points to a UFO file. It provides the outline geometry, kerning and font.info that we want to work with. + +```python +s0 = SourceDescriptor() +s0.path = "my/path/to/thin.ufo" +s0.name = "master.thin" +s0.location = dict(weight=0) +doc.addSource(s0) +``` + +* You'll need to have at least 2 sources in your document, so go ahead and add another one. +* The **location** attribute is a dictionary with the designspace location for this master. +* The axis names in the location have to match one of the `axis.name` values you defined before. +* The **path** attribute is the absolute path to an existing UFO. +* The **name** attribute is a unique name for this source used to keep track it. + +So go ahead and add another master: + +```python +s1 = SourceDescriptor() +s1.path = "my/path/to/bold.ufo" +s1.name = "master.bold" +s1.location = dict(weight=1000) +doc.addSource(s1) +``` +### Option: exclude glyphs + +By default all glyphs in a source will be processed. If you want to exclude certain glyphs, add their names to the `mutedGlyphNames` list. + +```python +s1.mutedGlyphNames = ["A.test", "A.old"] +``` + +## Make an instance object + +An **instance** is description of a UFO that you want to generate with the designspace. For an instance you can define more things. If you want to generate UFO instances with MutatorMath then you can define different names and set flags for if you want to generate kerning and font info and so on. You can also set a path where to generate the instance. + +```python +i0 = InstanceDescriptor() +i0.familyName = "MyVariableFontPrototype" +i0.styleName = "Medium" +i0.path = os.path.join(root, "instances","MyVariableFontPrototype-Medium.ufo") +i0.location = dict(weight=500) +i0.kerning = True +i0.info = True +doc.addInstance(i0) +``` +* The `path` attribute needs to be the absolute (real or intended) path for the instance. When the document is saved this path will written as relative to the path of the document. +* instance paths should be on the same level as the document, or in a level below. +* Instances for MutatorMath will generate to UFO. +* Instances for variable fonts become **named instances**. + +### Option: add more names + +If you want you can add a PostScript font name, a stylemap familyName and a stylemap styleName. + +```python +i0.postScriptFontName = "MyVariableFontPrototype-Medium" +i0.styleMapFamilyName = "MyVarProtoMedium" +i0.styleMapStyleName = "regular" +``` + +### Option: add glyph specific masters +This bit is not supported by OpenType variable fonts, but it is needed for some designspaces intended for generating instances with MutatorMath. The code becomes a bit verbose, so you're invited to wrap this into something clever. + +```python +# we're making a dict with all sorts of +#(optional) settings for a glyph. +#In this example: the dollar. +glyphData = dict(name="dollar", unicodeValue=0x24) + +# you can specify a different location for a glyph +glyphData['instanceLocation'] = dict(weight=500) + +# You can specify different masters +# for this specific glyph. +# You can also give those masters new +# locations. It's a miniature designspace. +# Remember the "name" attribute we assigned to the sources? +glyphData['masters'] = [ + dict(font="master.thin", + glyphName="dollar.nostroke", + location=dict(weight=0)), + dict(font="master.bold", + glyphName="dollar.nostroke", + location=dict(weight=1000)), + ] + +# With all of that set up, store it in the instance. +i4.glyphs['dollar'] = glyphData +``` + +# Saving + +```python +path = "myprototype.designspace" +doc.write(path) +``` + +# Reading old designspaces + +Old designspace files might not contain `axes` definitions. This is how you reconstruct the axes from the extremes of the source locations + +```python +doc.checkAxes() +``` + +This is how you check the default font. + +```python +doc.checkDefault() +``` + + +# Generating? + +You can generate the UFO's with MutatorMath: + +```python +from mutatorMath.ufo import build +build("whatevs/myprototype.designspace") +``` +* Assuming the outline data in the masters is compatible. + +Or you can use the file in making a **variable font** with varlib. +