WIP: implement conditionSets in designspaceLib.RuleDescriptor (#1255)

* [designspaceLib] WIP: add conditionSets to RuleDescriptor...

the BaseDocReader still needs to be updated to be able to read both
legacy condition-without-conditionset and new-style conditionset
elements.

* test_rules: replace rule.conditions with conditionSets

* Tests: update test.designspace to use conditionset; replace 'with' with 'byname' in sub element

I don't like too much that now 'byname' precedes alphabetically 'name'.
We need to see if ElementTree allows to sort attribute arbitrarily

* Change versionnumber to 4.0 - would that be enough, or should there be seperate major / minor fields?
If we bump the version up to numeral 4, I'd like the check for the with / byname attributes. We can read the old `byname` and only write the new `with`.
Remove support for `initial` in axis attributes.

* WIP - formatversion, with attribute,

* WIP
anything that is not version 4.0 is version 3.0
added some local paths to the test so I can see what it's generating. I can take this out later.

* WIP
read the conditionsets properly. This makes the tests fail no longer.

* WIP
disambiguate axis tags from axis names. Test axes are now called "axisName_x", tags are called "TAGX"

* WIP forgot to push

* WIP
Raise `DesignSpaceDocumentError` when no maximum or minimum are given in a condition.
Add test for such an incomplete rule.
Remove path cruft.

* WIP improved test of raised error.

* WIP because no bot.

* WIP more spaces around percents.

* WIP - don't skip empty subs, just load whatever is there.

* WIP do not permit empty axes in a location when evaluating a rule. Fix some tests.

* WIP missed a str(tempdir), some rule tests.

* WIP proposal for changes to the designspace spec to the `rule` and `sub` elements.

* WIP edits.

* WIP. Typo.
This commit is contained in:
Cosimo Lupo 2018-05-14 10:29:48 +01:00 committed by Erik van Blokland
parent 4da8f43eeb
commit ceb41ec484
4 changed files with 234 additions and 184 deletions

View File

@ -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.
<rule name="named.rule.1">
<condition minimum="250" maximum="750" name="weight" />
<condition minimum="50" maximum="100" name="width" />
<sub name="dollar" byname="dollar.alt"/>
<sub name="dollar" with="dollar.alt"/>
</rule>
</rules>
@ -946,7 +943,7 @@ Example with ``conditionsets``. All conditions in a conditionset must be true.
<condition ... />
<condition ... />
</conditionset>
<sub name="dollar" byname="dollar.alt"/>
<sub name="dollar" with="dollar.alt"/>
</rule>
</rules>

View File

@ -160,45 +160,49 @@ class SourceDescriptor(SimpleDescriptor):
class RuleDescriptor(SimpleDescriptor):
"""<!-- optional: list of substitution rules -->
<rules>
<rule name="vertical.bars" enabled="true">
<sub name="cent" byname="cent.alt"/>
<sub name="dollar" byname="dollar.alt"/>
<condition tag="wght" minimum ="250.000000" maximum ="750.000000"/>
<condition tag="wdth" minimum ="100"/>
<condition tag="opsz" minimum="10" maximum="40"/>
<rule name="vertical.bars">
<conditionset>
<condition minimum="250.000000" maximum="750.000000" name="weight"/>
<condition minimum="100" name="width"/>
<condition minimum="10" maximum="40" name="optical"/>
</conditionset>
<sub name="cent" with="cent.alt"/>
<sub name="dollar" with="dollar.alt"/>
</rule>
</rules>
Discussion:
use axis names rather than tags - then we can evaluate the rule without having to look up the axes.
remove the subs from the rule.
remove 'enabled' attr form rule
"""
_attrs = ['name', 'conditions', 'subs'] # what do we need here
_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)

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<designspace format="3">
<designspace format="4.0">
<axes>
<axis default="0" maximum="1000" minimum="0" name="weight" tag="wght">
<labelname xml:lang="en">Wéíght</labelname>
@ -14,8 +14,10 @@
</axes>
<rules>
<rule name="named.rule.1">
<condition maximum="1" minimum="0" name="aaaa" />
<condition maximum="3" minimum="2" name="bbbb" />
<conditionset>
<condition maximum="1" minimum="0" name="axisName_a" />
<condition maximum="3" minimum="2" name="axisName_b" />
</conditionset>
<sub name="a" with="a.alt" />
</rule>
</rules>

View File

@ -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.