diff --git a/Doc/source/designspaceLib/readme.rst b/Doc/source/designspaceLib/readme.rst index 3aad060be..9eaf67772 100644 --- a/Doc/source/designspaceLib/readme.rst +++ b/Doc/source/designspaceLib/readme.rst @@ -84,17 +84,12 @@ Rules ***** Rules describe designspace areas in which one glyph should be replaced by another. -A rule has a name and a number of conditionsets. The rule also contains a list of glyphname -pairs: the glyphs that need to be substituted. For a rule to be triggered -only one of the conditionsets needs to be true, ``OR``. Within a conditionset all -conditions need to be true, ``AND``. +A rule has a name and a number of conditionsets. The rule also contains a list of +glyphname pairs: the glyphs that need to be substituted. For a rule to be triggered +**only one** of the conditionsets needs to be true, ``OR``. Within a conditionset +**all** conditions need to be true, ``AND``. -Variable fonts -======================= - -- 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 ``sub`` element contains a pair of glyphnames. The ``name`` attribute is the glyph that should be visible when the rule evaluates to **False**. The ``with`` attribute is the glyph that should be visible when the rule evaluates to **True**. UFO instances ============= @@ -547,7 +542,7 @@ Attributes to the root path of this document. The path can be at the same level as the document or lower. - ``layer``: optional, string. The name of the layer in the source file. - If no layer attribute is given assume it is the foreground layer. + If no layer attribute is given assume the foreground layer should be used. .. 31-lib-element: @@ -856,8 +851,11 @@ Example - Defines a named rule. - Each ``rule`` element contains one or more ``conditionset`` elements. - Only one ``conditionset`` needs to be true to trigger the rule. -- All conditions must be true to make the ``conditionset`` true. -- For backwards compatibility a ``rule`` can contain ``condition`` elements outside of a conditionset. These are then understood to be part of a single, implied, ``conditionset``. +- All conditions in a ``conditionset`` must be true to make the ``conditionset`` true. +- For backwards compatibility a ``rule`` can contain ``condition`` elements outside of a conditionset. These are then understood to be part of a single, implied, ``conditionset``. Note: these conditions should be written wrapped in a conditionset. +- A rule element needs to contain one or more ``sub`` elements in order to be compiled to a variable font. +- Rules without sub elements should be ignored when compiling a font. +- For authoring tools it might be necessary to save designspace files without ``sub`` elements just because the work is incomplete. .. attributes-11: @@ -865,7 +863,8 @@ Attributes ---------- - ``name``: optional, string. A unique name that can be used to - identify this rule if it needs to be referenced elsewhere. + identify this rule if it needs to be referenced elsewhere. The name + is not important for compiling variable fonts. 5.1.1 conditionset element ======================= @@ -879,7 +878,7 @@ Attributes ======================= - Child element of ``conditionset`` -- Between the ``minimum`` and ``maximum`` this rule is ``true``. +- 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``. - The condition must contain at least a minimum or maximum or both. @@ -900,9 +899,7 @@ Attributes ================= - 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. +- Defines which glyph to replace when the rule evaluates to **True**. .. attributes-13: @@ -911,7 +908,7 @@ 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``: string, required. The name of the glyph it is replaced with. .. example-7: @@ -928,7 +925,7 @@ contained in a conditionset. - + @@ -946,7 +943,7 @@ Example with ``conditionsets``. All conditions in a conditionset must be true. - + diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py index 3be15e33b..6d2d84d62 100644 --- a/Lib/fontTools/designspaceLib/__init__.py +++ b/Lib/fontTools/designspaceLib/__init__.py @@ -160,45 +160,49 @@ class SourceDescriptor(SimpleDescriptor): 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 + _attrs = ['name', 'conditionSets', '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") + self.conditionSets = [] # list of list of dict(name='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 + """ Return True if any of the rule's conditionsets matches the + given location. + """ + return any(evaluateConditions(c, location) for c in rule.conditionSets) + + +def evaluateConditions(conditions, location): + """ Return True if all the conditions matches the given location. + If a condition has no minimum, check for < maximum. + If a condition has no maximum, check for > minimum. + """ + for cd in conditions: + value = location[cd['name']] if cd.get('minimum') is None: - if not location[cd['name']] <= cd['maximum']: + if value > 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']: + if cd['minimum'] > value: return False + elif not cd['minimum'] <= value <= cd['maximum']: + return False return True + def processRules(rules, location, glyphNames): """ Apply these rules at this location to these glyphnames.minimum - rule order matters @@ -357,13 +361,9 @@ class BaseDocWriter(object): def __init__(self, documentPath, documentObject): self.path = documentPath self.documentObject = documentObject - self.toolVersion = 3 + self.documentVersion = "4.0" 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.root.attrib['format'] = self.documentVersion self.axes = [] self.rules = [] @@ -434,27 +434,33 @@ class BaseDocWriter(object): # 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) + if ruleObject.name is not None: + ruleElement.attrib['name'] = ruleObject.name + for conditions in ruleObject.conditionSets: + conditionsetElement = ET.Element('conditionset') + for cond in 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')) + conditionsetElement.append(conditionElement) + if len(conditionsetElement): + ruleElement.append(conditionsetElement) + # XXX shouldn't we require at least one sub element? + # if not ruleObject.subs: + # raise DesignSpaceDocument('Invalid empty rule with no "sub" elements') 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) + if len(ruleElement): + self.root.findall('.rules')[0].append(ruleElement) def _addAxis(self, axisObject): self.axes.append(axisObject) @@ -648,10 +654,9 @@ class BaseDocReader(object): def __init__(self, documentPath, documentObject): self.path = documentPath self.documentObject = documentObject - self.documentObject.formatVersion = 0 tree = ET.parse(self.path) self.root = tree.getroot() - self.documentObject.formatVersion = int(self.root.attrib.get("format", 0)) + self.documentObject.formatVersion = self.root.attrib.get("format", "3.0") self.axes = [] self.rules = [] self.sources = [] @@ -684,22 +689,32 @@ class BaseDocReader(object): 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 conditionSetElement in ruleElement.findall('.conditionset'): + cds = [] + for conditionElement in conditionSetElement.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") + # test for things + if cd.get('minimum') is None and cd.get('maximum') is None: + if ruleObject.name is not None: + n = ruleObject.name + else: + n = "%d" % len(rules) + raise DesignSpaceDocumentError("No minimum or maximum defined in rule \"%s\"." % n) + cds.append(cd) + ruleObject.conditionSets.append(cds) for subElement in ruleElement.findall('.sub'): a = subElement.attrib['name'] b = subElement.attrib['with'] @@ -721,15 +736,7 @@ class BaseDocReader(object): 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.default = float(axisElement.attrib.get("default")) axisObject.tag = axisElement.attrib.get("tag") for mapElement in axisElement.findall('map'): a = float(mapElement.attrib['input']) @@ -814,7 +821,7 @@ class BaseDocReader(object): sourceName = sourceElement.attrib.get('name') if sourceName is None: # add a temporary source name - sourceName = "temp_master.%d"%(sourceCount) + 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 @@ -1243,7 +1250,7 @@ class DesignSpaceDocument(object): 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.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: @@ -1268,7 +1275,7 @@ class DesignSpaceDocument(object): 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)) + 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 @@ -1278,7 +1285,7 @@ class DesignSpaceDocument(object): 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)) + self.logger.info("Warning: mismatched default value for axis %s and neutral master. Master value outside of axis bounds" % (axisObj.name)) def getMutatorDefaultCandidate(self): # FIXME: original implementation using MutatorMath @@ -1416,18 +1423,21 @@ class DesignSpaceDocument(object): 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 + newConditionSets = [] + for conditions in rule.conditionSets: + newConditions = [] + for cond in 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)) + newConditionSets.append(newConditions) + rule.conditionSets = newConditionSets def rulesToFeature(doc, whiteSpace="\t", newLine="\n"): @@ -1438,11 +1448,11 @@ def rulesToFeature(doc, whiteSpace="\t", newLine="\n"): axisDims = {axis.tag: (axis.minimum, axis.maximum) for axis in doc.axes} text = [] for rule in doc.rules: - text.append("rule %s{"%rule.name) + 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) + text.append("%s%s %f %f;" % (whiteSpace, axisTag, axisMinimum, axisMaximum)) + text.append("} %s;" % rule.name) return newLine.join(text) diff --git a/Tests/designspaceLib/data/test.designspace b/Tests/designspaceLib/data/test.designspace index 43116deb2..cf7056b33 100644 --- a/Tests/designspaceLib/data/test.designspace +++ b/Tests/designspaceLib/data/test.designspace @@ -1,5 +1,5 @@ - + Wéíght @@ -14,8 +14,10 @@ - - + + + + diff --git a/Tests/designspaceLib/designspace_test.py b/Tests/designspaceLib/designspace_test.py index 5ee09b27d..256e45742 100644 --- a/Tests/designspaceLib/designspace_test.py +++ b/Tests/designspaceLib/designspace_test.py @@ -9,7 +9,7 @@ import pytest from fontTools.misc.py23 import open from fontTools.designspaceLib import ( DesignSpaceDocument, SourceDescriptor, AxisDescriptor, RuleDescriptor, - InstanceDescriptor, evaluateRule, processRules, posix) + InstanceDescriptor, evaluateRule, processRules, posix, DesignSpaceDocumentError) def assert_equals_test_file(path, test_filename): @@ -149,8 +149,10 @@ def test_fill_document(tmpdir): # 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.conditionSets.append([ + dict(name='axisName_a', minimum=0, maximum=1), + dict(name='axisName_b', minimum=2, maximum=3) + ]) r1.subs.append(("a", "a.alt")) doc.addRule(r1) # write the document @@ -374,8 +376,10 @@ def test_localisedNames(tmpdir): # 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.conditionSets.append([ + dict(name='weight', minimum=200, maximum=500), + dict(name='width', minimum=0, maximum=150) + ]) r1.subs.append(("a", "a.alt")) doc.addRule(r1) # write the document @@ -412,8 +416,8 @@ def test_handleNoAxes(tmpdir): a.minimum = 0 a.maximum = 1000 a.default = 0 - a.name = "axisName%s"%(name) - a.tag = "ax_%d"%(value) + a.name = "axisName%s" % (name) + a.tag = "ax_%d" % (value) doc.addAxis(a) # add master 1 @@ -458,7 +462,6 @@ def test_handleNoAxes(tmpdir): verify.read(testDocPath) verify.write(testDocPath2) - def test_pathNameResolve(tmpdir): tmpdir = str(tmpdir) # test how descriptor.path and descriptor.filename are resolved @@ -575,26 +578,26 @@ def test_normalise(): a1.minimum = -1000 a1.maximum = 1000 a1.default = 0 - a1.name = "aaa" - a1.tag = "aaaa" + a1.name = "axisName_a" + a1.tag = "TAGA" doc.addAxis(a1) - assert doc.normalizeLocation(dict(aaa=0)) == {'aaa': 0.0} - assert doc.normalizeLocation(dict(aaa=1000)) == {'aaa': 1.0} + assert doc.normalizeLocation(dict(axisName_a=0)) == {'axisName_a': 0.0} + assert doc.normalizeLocation(dict(axisName_a=1000)) == {'axisName_a': 1.0} # clipping beyond max values: - assert doc.normalizeLocation(dict(aaa=1001)) == {'aaa': 1.0} - assert doc.normalizeLocation(dict(aaa=500)) == {'aaa': 0.5} - assert doc.normalizeLocation(dict(aaa=-1000)) == {'aaa': -1.0} - assert doc.normalizeLocation(dict(aaa=-1001)) == {'aaa': -1.0} + assert doc.normalizeLocation(dict(axisName_a=1001)) == {'axisName_a': 1.0} + assert doc.normalizeLocation(dict(axisName_a=500)) == {'axisName_a': 0.5} + assert doc.normalizeLocation(dict(axisName_a=-1000)) == {'axisName_a': -1.0} + assert doc.normalizeLocation(dict(axisName_a=-1001)) == {'axisName_a': -1.0} # anisotropic coordinates normalise to isotropic - assert doc.normalizeLocation(dict(aaa=(1000, -1000))) == {'aaa': 1.0} + assert doc.normalizeLocation(dict(axisName_a=(1000, -1000))) == {'axisName_a': 1.0} doc.normalize() r = [] for axis in doc.axes: r.append((axis.name, axis.minimum, axis.default, axis.maximum)) r.sort() - assert r == [('aaa', -1.0, 0.0, 1.0)] + assert r == [('axisName_a', -1.0, 0.0, 1.0)] doc = DesignSpaceDocument() # write some axes @@ -602,24 +605,24 @@ def test_normalise(): a2.minimum = 100 a2.maximum = 1000 a2.default = 100 - a2.name = "bbb" + a2.name = "axisName_b" doc.addAxis(a2) - assert doc.normalizeLocation(dict(bbb=0)) == {'bbb': 0.0} - assert doc.normalizeLocation(dict(bbb=1000)) == {'bbb': 1.0} + assert doc.normalizeLocation(dict(axisName_b=0)) == {'axisName_b': 0.0} + assert doc.normalizeLocation(dict(axisName_b=1000)) == {'axisName_b': 1.0} # clipping beyond max values: - assert doc.normalizeLocation(dict(bbb=1001)) == {'bbb': 1.0} - assert doc.normalizeLocation(dict(bbb=500)) == {'bbb': 0.4444444444444444} - assert doc.normalizeLocation(dict(bbb=-1000)) == {'bbb': 0.0} - assert doc.normalizeLocation(dict(bbb=-1001)) == {'bbb': 0.0} + assert doc.normalizeLocation(dict(axisName_b=1001)) == {'axisName_b': 1.0} + assert doc.normalizeLocation(dict(axisName_b=500)) == {'axisName_b': 0.4444444444444444} + assert doc.normalizeLocation(dict(axisName_b=-1000)) == {'axisName_b': 0.0} + assert doc.normalizeLocation(dict(axisName_b=-1001)) == {'axisName_b': 0.0} # anisotropic coordinates normalise to isotropic - assert doc.normalizeLocation(dict(bbb=(1000,-1000))) == {'bbb': 1.0} - assert doc.normalizeLocation(dict(bbb=1001)) == {'bbb': 1.0} + assert doc.normalizeLocation(dict(axisName_b=(1000,-1000))) == {'axisName_b': 1.0} + assert doc.normalizeLocation(dict(axisName_b=1001)) == {'axisName_b': 1.0} doc.normalize() r = [] for axis in doc.axes: r.append((axis.name, axis.minimum, axis.default, axis.maximum)) r.sort() - assert r == [('bbb', 0.0, 0.0, 1.0)] + assert r == [('axisName_b', 0.0, 0.0, 1.0)] doc = DesignSpaceDocument() # write some axes @@ -687,15 +690,15 @@ def test_rules(tmpdir): doc = DesignSpaceDocument() # write some axes a1 = AxisDescriptor() - a1.tag = "taga" - a1.name = "aaaa" + a1.tag = "TAGA" + a1.name = "axisName_a" a1.minimum = 0 a1.maximum = 1000 a1.default = 0 doc.addAxis(a1) a2 = AxisDescriptor() - a2.tag = "tagb" - a2.name = "bbbb" + a2.tag = "TAGB" + a2.name = "axisName_b" a2.minimum = 0 a2.maximum = 3000 a2.default = 0 @@ -703,80 +706,88 @@ def test_rules(tmpdir): 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.conditionSets.append([ + dict(name='axisName_a', minimum=0, maximum=1000), + dict(name='axisName_b', minimum=0, maximum=3000) + ]) r1.subs.append(("a", "a.alt")) # rule with minium and maximum doc.addRule(r1) assert len(doc.rules) == 1 - assert len(doc.rules[0].conditions) == 2 - assert evaluateRule(r1, dict(aaaa = 500, bbbb = 0)) == True - assert evaluateRule(r1, dict(aaaa = 0, bbbb = 0)) == True - assert evaluateRule(r1, dict(aaaa = 1000, bbbb = 0)) == True - assert evaluateRule(r1, dict(aaaa = 1000, bbbb = -100)) == False - assert evaluateRule(r1, dict(aaaa = 1000.0001, bbbb = 0)) == False - assert evaluateRule(r1, dict(aaaa = -0.0001, bbbb = 0)) == False - assert evaluateRule(r1, dict(aaaa = -100, bbbb = 0)) == False - assert processRules([r1], dict(aaaa = 500), ["a", "b", "c"]) == ['a.alt', 'b', 'c'] - assert processRules([r1], dict(aaaa = 500), ["a.alt", "b", "c"]) == ['a.alt', 'b', 'c'] - assert processRules([r1], dict(aaaa = 2000), ["a", "b", "c"]) == ['a', 'b', 'c'] + assert len(doc.rules[0].conditionSets) == 1 + assert len(doc.rules[0].conditionSets[0]) == 2 + assert evaluateRule(r1, dict(axisName_a = 500, axisName_b = 0)) == True + assert evaluateRule(r1, dict(axisName_a = 0, axisName_b = 0)) == True + assert evaluateRule(r1, dict(axisName_a = 1000, axisName_b = 0)) == True + assert evaluateRule(r1, dict(axisName_a = 1000, axisName_b = -100)) == False + assert evaluateRule(r1, dict(axisName_a = 1000.0001, axisName_b = 0)) == False + assert evaluateRule(r1, dict(axisName_a = -0.0001, axisName_b = 0)) == False + assert evaluateRule(r1, dict(axisName_a = -100, axisName_b = 0)) == False + assert processRules([r1], dict(axisName_a = 500, axisName_b = 0), ["a", "b", "c"]) == ['a.alt', 'b', 'c'] + assert processRules([r1], dict(axisName_a = 500, axisName_b = 0), ["a.alt", "b", "c"]) == ['a.alt', 'b', 'c'] + assert processRules([r1], dict(axisName_a = 2000, axisName_b = 0), ["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.conditionSets.append([dict(name='axisName_a', maximum=500)]) r2.subs.append(("b", "b.alt")) - assert evaluateRule(r2, dict(aaaa = 0)) == True - assert evaluateRule(r2, dict(aaaa = -500)) == True - assert evaluateRule(r2, dict(aaaa = 1000)) == False + assert evaluateRule(r2, dict(axisName_a = 0)) == True + assert evaluateRule(r2, dict(axisName_a = -500)) == True + assert evaluateRule(r2, dict(axisName_a = 1000)) == False # rule with only a minimum r3 = RuleDescriptor() r3.name = "named.rule.3" - r3.conditions.append(dict(name='aaaa', minimum=500)) + r3.conditionSets.append([dict(name='axisName_a', minimum=500)]) r3.subs.append(("c", "c.alt")) - assert evaluateRule(r3, dict(aaaa = 0)) == False - assert evaluateRule(r3, dict(aaaa = 1000)) == True - assert evaluateRule(r3, dict(bbbb = 1000)) == True + assert evaluateRule(r3, dict(axisName_a = 0)) == False + assert evaluateRule(r3, dict(axisName_a = 1000)) == True + assert evaluateRule(r3, dict(axisName_a = 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.conditionSets.append([ + dict(name='axisName_a', minimum=500), + dict(name='axisName_b', maximum=500) + ]) r4.subs.append(("c", "c.alt")) - assert evaluateRule(r4, dict()) == True # is this what we expect though? - assert evaluateRule(r4, dict(aaaa = 1000, bbbb = 0)) == True - assert evaluateRule(r4, dict(aaaa = 0, bbbb = 0)) == False - assert evaluateRule(r4, dict(aaaa = 1000, bbbb = 1000)) == False + assert evaluateRule(r4, dict(axisName_a = 1000, axisName_b = 0)) == True + assert evaluateRule(r4, dict(axisName_a = 0, axisName_b = 0)) == False + assert evaluateRule(r4, dict(axisName_a = 1000, axisName_b = 1000)) == False a1 = AxisDescriptor() a1.minimum = 0 a1.maximum = 1000 a1.default = 0 - a1.name = "aaaa" - a1.tag = "aaaa" + a1.name = "axisName_a" + a1.tag = "TAGA" b1 = AxisDescriptor() b1.minimum = 2000 b1.maximum = 3000 b1.default = 2000 - b1.name = "bbbb" - b1.tag = "bbbb" + b1.name = "axisName_b" + b1.tag = "TAGB" doc.addAxis(a1) doc.addAxis(b1) - assert doc._prepAxesForBender() == {'aaaa': {'map': [], 'name': 'aaaa', 'default': 0, 'minimum': 0, 'maximum': 1000, 'tag': 'aaaa'}, 'bbbb': {'map': [], 'name': 'bbbb', 'default': 2000, 'minimum': 2000, 'maximum': 3000, 'tag': 'bbbb'}} + assert doc._prepAxesForBender() == {'axisName_a': {'map': [], 'name': 'axisName_a', 'default': 0, 'minimum': 0, 'maximum': 1000, 'tag': 'TAGA'}, 'axisName_b': {'map': [], 'name': 'axisName_b', 'default': 2000, 'minimum': 2000, 'maximum': 3000, 'tag': 'TAGB'}} - assert doc.rules[0].conditions == [{'minimum': 0, 'maximum': 1000, 'name': 'aaaa'}, {'minimum': 0, 'maximum': 3000, 'name': 'bbbb'}] + assert doc.rules[0].conditionSets == [[ + {'minimum': 0, 'maximum': 1000, 'name': 'axisName_a'}, + {'minimum': 0, 'maximum': 3000, 'name': 'axisName_b'}]] assert doc.rules[0].subs == [('a', 'a.alt')] doc.normalize() assert doc.rules[0].name == 'named.rule.1' - assert doc.rules[0].conditions == [{'minimum': 0.0, 'maximum': 1.0, 'name': 'aaaa'}, {'minimum': 0.0, 'maximum': 1.0, 'name': 'bbbb'}] + assert doc.rules[0].conditionSets == [[ + {'minimum': 0.0, 'maximum': 1.0, 'name': 'axisName_a'}, + {'minimum': 0.0, 'maximum': 1.0, 'name': 'axisName_b'}]] doc.write(testDocPath) new = DesignSpaceDocument() @@ -787,6 +798,36 @@ def test_rules(tmpdir): new.write(testDocPath2) +def test_incompleteRule(tmpdir): + tmpdir = str(tmpdir) + testDocPath1 = os.path.join(tmpdir, "testIncompleteRule.designspace") + doc = DesignSpaceDocument() + r1 = RuleDescriptor() + r1.name = "incomplete.rule.1" + r1.conditionSets.append([ + dict(name='axisName_a', minimum=100), + dict(name='axisName_b', maximum=200) + ]) + r1.subs.append(("c", "c.alt")) + doc.addRule(r1) + doc.write(testDocPath1) + __removeConditionMinimumMaximumDesignSpace(testDocPath1) + new = DesignSpaceDocument() + with pytest.raises(DesignSpaceDocumentError) as excinfo: + new.read(testDocPath1) + assert "No minimum or maximum defined in rule" in str(excinfo.value) + +def __removeConditionMinimumMaximumDesignSpace(path): + # only for testing, so we can make an invalid designspace file + # without making the designSpaceDocument also support it. + f = open(path, 'r', encoding='utf-8') + d = f.read() + f.close() + d = d.replace(' minimum="100"', '') + f = open(path, 'w', encoding='utf-8') + f.write(d) + f.close() + def __removeAxesFromDesignSpace(path): # only for testing, so we can make an invalid designspace file # without making the designSpaceDocument also support it.