Add optimization that prunes nested <g>-tags

An optimization that prunes nested <g>-tags when they contain exactly
one <g> and nothing else (except whitespace nodes).  This looks a bit
like `removeNestedGroups` except it only touches <g> tags without
attributes (but can remove <g>-tags completely from a tree, whereas
this optimization always leaves at least one <g> tag behind).

Closes: #215
Signed-off-by: Niels Thykier <niels@thykier.net>
This commit is contained in:
Niels Thykier 2020-05-17 20:20:59 +00:00
parent 7e917c9ca0
commit e8e104d8b8
No known key found for this signature in database
GPG key ID: A65B78DBE67C7AAC
5 changed files with 216 additions and 0 deletions

View file

@ -1143,6 +1143,78 @@ def moveCommonAttributesToParentGroup(elem, referencedElements):
return num return num
def mergeSingleChildGroupIntoParent(elem):
"""
Merge <g X></g Y>...</g></g> into <g X Y>...</g>
Optimize the pattern:
<g a="1" c="4"><g a="2" b="3">...</g>/</g>
into
<g a="2" b="3" c="4">...</g>
This is only possible when:
* The parent has exactly one <g>-child node (ignoring whitespace)
* The child node is mergeable (per :func:`g_tag_is_unmergeable`)
Note that this function overlaps to some extend with `removeNestedGroups`.
However, `removeNestedGroups` work entirely on empty <g> and can completely
remove empty `<g>` tags. This works on any <g> tag containing <g> tags
(regardless of their attributes) and as such this function can never
completely eliminate all <g>-tags (but it does deal with attributes).
This function acts recursively on the given element.
"""
num = 0
# Depth first - fix child notes first
for childNode in elem.childNodes:
if childNode.nodeType == Node.ELEMENT_NODE:
num += mergeSingleChildGroupIntoParent(childNode)
# The elem node must be a <g> tag for this to be relevant.
if elem.nodeType != Node.ELEMENT_NODE or elem.nodeName != 'g' or \
elem.namespaceURI != NS['SVG']:
return num
if g_tag_is_unmergeable(elem):
# elem itself is protected, then leave it alone.
return num
g_node_idx = None
for idx, node in enumerate(elem.childNodes):
if node.nodeType == Node.TEXT_NODE and not node.nodeValue.strip():
# whitespace-only node; ignore
continue
if node.nodeType != Node.ELEMENT_NODE or node.nodeName != 'g' or \
node.namespaceURI != NS['SVG']:
# The elem node has something other than <g> tag; then this
# optimization does not apply
return num
if g_tag_is_unmergeable(node) or g_node_idx is not None:
# The elem node has two or more <g> tags or the <g> node it has
# is unmergeable; then this optimization does not apply
return num
g_node_idx = idx
# We got a optimization candidate
g_node = elem.childNodes[g_node_idx]
elem.childNodes.remove(g_node)
elem.childNodes[g_node_idx:g_node_idx] = g_node.childNodes
g_node.childNodes = []
for a in g_node.attributes.values():
elem.setAttribute(a.nodeName, a.nodeValue)
num += 1
return num
def mergeSiblingGroupsWithCommonAttributes(elem): def mergeSiblingGroupsWithCommonAttributes(elem):
""" """
Merge two or more sibling <g> elements with the identical attributes. Merge two or more sibling <g> elements with the identical attributes.
@ -3754,6 +3826,7 @@ def scourString(in_string, options=None):
# Collapse groups LAST, because we've created groups. If done before # Collapse groups LAST, because we've created groups. If done before
# moveAttributesToParentGroup, empty <g>'s may remain. # moveAttributesToParentGroup, empty <g>'s may remain.
if options.group_collapse: if options.group_collapse:
_num_elements_removed += mergeSingleChildGroupIntoParent(doc.documentElement)
while removeNestedGroups(doc.documentElement) > 0: while removeNestedGroups(doc.documentElement) > 0:
pass pass

View file

@ -2075,6 +2075,104 @@ class MustKeepGInSwitch2(unittest.TestCase):
'Erroneously removed a <g> in a <switch>') 'Erroneously removed a <g> in a <switch>')
class GroupParentMerge(unittest.TestCase):
def test_parent_merge(self):
doc = scourXmlFile('unittests/group-parent-merge.svg',
parse_args([]))
g_tags = doc.getElementsByTagName('g')
attrs = {
'font-family': 'Liberation Sans,Arial,Helvetica,sans-serif',
'text-anchor': 'middle',
'font-weight': '400',
'font-size': '24',
}
self.assertEqual(g_tags.length, 1,
'Inline single-child node <g> tags into parent <g>-tags')
g_tag = g_tags[0]
for attr_name, attr_value in attrs.items():
self.assertEqual(g_tag.getAttribute(attr_name), attr_value,
'Parent now has inherited attributes of obsolete <g>-tags')
def test_parent_merge_disabled(self):
doc = scourXmlFile('unittests/group-parent-merge.svg',
parse_args(['--disable-group-collapsing']))
g_tags = doc.getElementsByTagName('g')
attrs = {
'font-family': 'Liberation Sans,Arial,Helvetica,sans-serif',
'text-anchor': '',
'font-weight': '',
'font-size': '',
}
self.assertEqual(g_tags.length, 4,
'Inline single-child node <g> tags into parent <g>-tags')
# There should be exactly one <g> tag in the top of the document
# Note that the order returned by getElementsByTagName is not specified
# so we do not rely on its return value
g_tags = [g for g in doc.documentElement.childNodes if g.nodeName == 'g']
self.assertEqual(len(g_tags), 1,
'Optimization must not move the <g> up to the root')
g_tag = g_tags[0]
for attr_name, attr_value in attrs.items():
self.assertEqual(g_tag.getAttribute(attr_name), attr_value,
'Parent now has inherited attributes of obsolete <g>-tags')
def test_parent_merge2(self):
doc = scourXmlFile('unittests/group-parent-merge2.svg',
parse_args([]))
attrs = {
'font-family': 'Liberation Sans,Arial,Helvetica,sans-serif',
'text-anchor': 'middle',
'font-weight': '400',
'font-size': '', # The top-level g-node cannot have gotten this.
}
# There is one inner <g> that cannot be optimized, so there must be 2
# <g> tags in total
self.assertEqual(doc.getElementsByTagName('g').length, 2,
'Inline single-child node <g> tags into parent <g>-tags')
# There should be exactly one <g> tag in the top of the document
# Note that the order returned by getElementsByTagName is not specified
# so we do not rely on its return value
g_tags = [g for g in doc.documentElement.childNodes if g.nodeName == 'g']
self.assertEqual(len(g_tags), 1,
'Optimization must not move the <g> up to the root')
g_tag = g_tags[0]
for attr_name, attr_value in attrs.items():
self.assertEqual(g_tag.getAttribute(attr_name), attr_value,
'Parent now has inherited attributes of obsolete <g>-tags')
def test_parent_merge3(self):
doc = scourXmlFile('unittests/group-parent-merge3.svg',
parse_args(['--protect-ids-list=foo']))
attrs = {
'font-family': 'Liberation Sans,Arial,Helvetica,sans-serif',
'text-anchor': 'middle',
'font-weight': '400',
'font-size': '', # The top-level g-node cannot have gotten this.
}
# There is one inner <g> that cannot be optimized, so there must be 2
# <g> tags in total
self.assertEqual(doc.getElementsByTagName('g').length, 2,
'Inline single-child node <g> tags into parent <g>-tags')
self.assertIsNotNone(doc.getElementById('foo'), 'The inner <g> was left untouched')
# There should be exactly one <g> tag in the top of the document
# Note that the order returned by getElementsByTagName is not specified
# so we do not rely on its return value
g_tags = [g for g in doc.documentElement.childNodes if g.nodeName == 'g']
self.assertEqual(len(g_tags), 1,
'Optimization must not move the <g> up to the root')
g_tag = g_tags[0]
for attr_name, attr_value in attrs.items():
self.assertEqual(g_tag.getAttribute(attr_name), attr_value,
'Parent now has inherited attributes of obsolete <g>-tags')
class GroupSiblingMerge(unittest.TestCase): class GroupSiblingMerge(unittest.TestCase):
def test_sibling_merge(self): def test_sibling_merge(self):

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g font-family="Liberation Sans,Arial,Helvetica,sans-serif">
<g text-anchor="middle">
<g font-weight="400">
<g font-size="24">
<text x="50" y="30">Text1</text>
<text x="50" y="55">Text2</text>
<text x="50" y="80">Text3</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 491 B

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g font-family="Liberation Sans,Arial,Helvetica,sans-serif">
<g text-anchor="middle">
<g font-weight="400">
<text x="50" y="30">Text1</text>
<g font-size="24">
<text x="50" y="55">Text2</text>
<text x="50" y="80">Text3</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g font-family="Liberation Sans,Arial,Helvetica,sans-serif">
<g text-anchor="middle">
<g font-weight="400">
<g id="foo" font-size="24">
<text x="50" y="30">Text1</text>
<text x="50" y="55">Text2</text>
<text x="50" y="80">Text3</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 500 B