diff --git a/odoo/addons/test_testing_utilities/tests/__init__.py b/odoo/addons/test_testing_utilities/tests/__init__.py
index 7193c4e5f94..78398a30bce 100644
--- a/odoo/addons/test_testing_utilities/tests/__init__.py
+++ b/odoo/addons/test_testing_utilities/tests/__init__.py
@@ -2,3 +2,4 @@
from . import test_methods
from . import test_lxml
from . import test_form_impl
+from . import test_xml_tools
diff --git a/odoo/addons/test_testing_utilities/tests/test_xml_tools.py b/odoo/addons/test_testing_utilities/tests/test_xml_tools.py
new file mode 100644
index 00000000000..8f7d59171e5
--- /dev/null
+++ b/odoo/addons/test_testing_utilities/tests/test_xml_tools.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from lxml import etree
+from odoo.tests import common
+from odoo.tools.xml_utils import cleanup_xml_node
+
+
+class TestXMLTools(common.TransactionCase):
+
+ def setUp(self):
+ super(TestXMLTools, self).setUp()
+ self.qweb_poor = self.env()['ir.ui.view'].create({
+ 'type': 'qweb',
+ 'arch_db': """
+
+
+ text
+ \t
+
+
+
"""})
+
+ def test_cleanup_xml_node_no_modif(self):
+ # Qweb removes comments and any whitespace before first tag, no other content affected
+ expected = """
+
+ text
+ \t
+
+
+
"""
+ qweb = self.env['ir.qweb']._render(self.qweb_poor.id)
+ self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_text=False, remove_blank_nodes=False, indent_level=-1)
+
+ def test_cleanup_xml_node_indent_level(self):
+ # Indentation level and spacing works as expected, nothing else affected
+ # (quirk: first tag not indented because indent is actually previous tag's tail)
+ expected = """
+__
+__text
+__
+___
+__
+__
+_
+"""
+ qweb = self.env['ir.qweb']._render(self.qweb_poor.id)
+ self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_text=False, remove_blank_nodes=False, indent_level=1, indent_space="_")
+
+ def test_cleanup_xml_node_keep_blank_text(self):
+ # Blank nodes are removed but not nodes containing blank text
+ expected = """
+ text
+
+
+"""
+ qweb = self.env['ir.qweb']._render(self.qweb_poor.id)
+ self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_text=False)
+
+ def test_cleanup_xml_node_keep_blank_nodes(self):
+ # Blank text is removed but blank (empty) nodes remain
+ expected = """
+
+ text
+
+
+
+
+
+"""
+ qweb = self.env['ir.qweb']._render(self.qweb_poor.id)
+ self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_nodes=False)
+
+ def test_cleanup_xml_t_call_indent(self):
+ # Indentation is fixed after t-call (which keeps indentation of called template)
+ template_1 = self.env['ir.ui.view'].create({
+ 'type': 'qweb',
+ 'arch_db': '''
+ This is content!
+
+'''})
+ template_2 = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': f'''
+
+
+
+
+'''})
+ expected = """
+
+
+ This is content!
+
+
+
+"""
+ qweb = self.env['ir.qweb']._render(template_2.id)
+ self.check_xml_cleanup_result_is_as_expected(qweb, expected)
+
+ def test_qweb_render_values_empty_nodes(self):
+ # Indentation is fixed and empty nodes are removed after conditional rendering
+ template_addresses = self.env['ir.ui.view'].create({
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+
+
+'''})
+ template_main = self.env['ir.ui.view'].create({
+ 'type': 'qweb',
+ 'arch_db': f'''
+
+
+
+
+
+
+
+
+'''})
+ expected = """
+ - 1
+ - 2
+ - Three
+
+ Baker street
+ 221B
+ London
+
+
+"""
+ qweb = self.env['ir.qweb']._render(template_main.id, {
+ 'items': [1, 2, "Three", False],
+ 'addressRecipient': {
+ 'number': '221B',
+ 'street': 'Baker street',
+ 'city': 'London',
+ },
+ 'addressSender': {
+ 'street': ' '
+ }
+ })
+ self.check_xml_cleanup_result_is_as_expected(qweb, expected)
+
+ def check_xml_cleanup_result_is_as_expected(self, original_string, expected_string, **kwargs):
+ result_string = etree.tostring(cleanup_xml_node(original_string, **kwargs)).decode()
+ self.assertEqual(expected_string, result_string)
+ self.assertNotEqual(expected_string, original_string)
diff --git a/odoo/tools/xml_utils.py b/odoo/tools/xml_utils.py
index 31d2707b6d0..d7d89d7e380 100644
--- a/odoo/tools/xml_utils.py
+++ b/odoo/tools/xml_utils.py
@@ -86,3 +86,56 @@ def create_xml_node(parent_node, node_name, node_value=None):
:rtype: etree._Element
"""
return create_xml_node_chain(parent_node, [node_name], node_value)[0]
+
+
+def cleanup_xml_node(xml_node_or_string, remove_blank_text=True, remove_blank_nodes=True, indent_level=0, indent_space=" "):
+ """Clean up the sub-tree of the provided XML node.
+
+ If the provided XML node is of type:
+ - etree._Element, it is modified in-place.
+ - string/bytes, it is first parsed into an etree._Element
+ :param xml_node_or_string (etree._Element, str): XML node (or its string/bytes representation)
+ :param remove_blank_text (bool): if True, removes whitespace-only text from nodes
+ :param remove_blank_nodes (bool): if True, removes leaf nodes with no text (iterative, depth-first, done after remove_blank_text)
+ :param indent_level (int): depth or level of node within root tree (use -1 to leave indentation as-is)
+ :param indent_space (str): string to use for indentation (use '' to remove all indentation)
+ :returns (etree._Element): clean node, same instance that was received (if applicable)
+ """
+ xml_node = xml_node_or_string
+
+ # Convert str/bytes to etree._Element
+ if isinstance(xml_node, str):
+ xml_node = xml_node.encode() # misnomer: fromstring actually reads bytes
+ if isinstance(xml_node, bytes):
+ xml_node = etree.fromstring(xml_node)
+
+ # Process leaf nodes iteratively
+ # Depth-first, so any inner node may become a leaf too (if children are removed)
+ def leaf_iter(parent_node, node, level):
+ for child_node in node:
+ leaf_iter(node, child_node, level if level < 0 else level + 1)
+
+ # Indentation
+ if level >= 0:
+ indent = '\n' + indent_space * level
+ if not node.tail or not node.tail.strip():
+ node.tail = '\n' if parent_node is None else indent
+ if len(node) > 0:
+ if not node.text or not node.text.strip():
+ # First child's indentation is parent's text
+ node.text = indent + indent_space
+ last_child = node[-1]
+ if last_child.tail == indent + indent_space:
+ # Last child's tail is parent's closing tag indentation
+ last_child.tail = indent
+
+ # Removal condition: node is leaf (not root nor inner node)
+ if parent_node is not None and len(node) == 0:
+ if remove_blank_text and node.text is not None and not node.text.strip():
+ # node.text is None iff node.tag is self-closing (text='' creates closing tag)
+ node.text = ''
+ if remove_blank_nodes and not (node.text or ''):
+ parent_node.remove(node)
+
+ leaf_iter(None, xml_node, indent_level)
+ return xml_node