diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index efc81f31e36a5b..eb4984e2b53d92 100644 --- a/Doc/library/xml.dom.minidom.rst +++ b/Doc/library/xml.dom.minidom.rst @@ -187,6 +187,12 @@ module documentation. This section lists the differences between the API and The *standalone* argument behaves exactly as in :meth:`writexml`. + No indentation is added inside an element + which is marked with ``xml:space="preserve"``, + which is declared in the DTD as not having element content, + or, in absence of such declaration, which contains text, + because this would change its content. + .. versionchanged:: 3.8 The :meth:`toprettyxml` method now preserves the attribute order specified by the user. @@ -194,6 +200,10 @@ module documentation. This section lists the differences between the API and .. versionchanged:: 3.9 The *standalone* parameter was added. + .. versionchanged:: next + Whitespace is no longer added inside an element with mixed content + or marked with ``xml:space="preserve"``. + .. _dom-example: DOM Example diff --git a/Doc/library/xml.etree.elementtree.rst b/Doc/library/xml.etree.elementtree.rst index 310ccd651e18c7..e06620bd12d6ee 100644 --- a/Doc/library/xml.etree.elementtree.rst +++ b/Doc/library/xml.etree.elementtree.rst @@ -603,8 +603,16 @@ Functions characters by default. For indenting partial subtrees inside of an already indented tree, pass the initial indentation level as *level*. + No whitespace is added inside an element + which is marked with ``xml:space="preserve"`` + or which contains text, because this would change its content. + .. versionadded:: 3.9 + .. versionchanged:: next + Whitespace is no longer added inside an element with mixed content + or marked with ``xml:space="preserve"``. + .. function:: iselement(element) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index f3ddae7a2b2fdd..274bf4edc50543 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -642,6 +642,14 @@ xml and :meth:`!Document.createEntityReference`. (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.) +* :meth:`~xml.dom.minidom.Node.toprettyxml` in :mod:`xml.dom.minidom` + and :func:`~xml.etree.ElementTree.indent` in :mod:`xml.etree.ElementTree` + no longer add whitespace inside an element + which is marked with ``xml:space="preserve"`` or which contains text. + :meth:`!toprettyxml` also takes into account + the content model declared in the DTD. + (Contributed by Serhiy Storchaka in :gh:`81623`.) + * Add :meth:`!GetSpecifiedAttributeCount` method to the :mod:`XML parser ` objects. It tells how many of the reported attributes were given in the start tag @@ -881,6 +889,15 @@ that may require changes to your code. Attributes defaulted in the DTD are no longer omitted when parsing. (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.) +* :meth:`~xml.dom.minidom.Node.toprettyxml` in :mod:`xml.dom.minidom` + and :func:`~xml.etree.ElementTree.indent` in :mod:`xml.etree.ElementTree` + no longer add whitespace inside an element + which is marked with ``xml:space="preserve"`` or which contains text, + because this changed the content of the element. + :meth:`!toprettyxml` also takes into account + the content model declared in the DTD. + (Contributed by Serhiy Storchaka in :gh:`81623`.) + * On Windows, seeking a pipe now fails instead of silently appearing to succeed: :func:`os.lseek` and :meth:`~io.IOBase.seek` raise :exc:`OSError`, and :meth:`~io.IOBase.seekable` returns ``False``. As a consequence, diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 3735a6046891ea..0d05a680608669 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -609,32 +609,76 @@ def testAltNewline(self): self.assertEqual(domstr, str.replace("\n", "\r\n")) def test_toprettyxml_with_text_nodes(self): - # see issue #4147, text nodes are not indented + # see gh-48397 and gh-81623, + # the content of an element with text is not changed decl = '\n' self.assertEqual(parseString('A').toprettyxml(), decl + 'A\n') self.assertEqual(parseString('AA').toprettyxml(), - decl + '\n\tA\n\tA\n\n') + decl + 'AA\n') self.assertEqual(parseString('AA').toprettyxml(), - decl + '\n\tA\n\tA\n\n') + decl + 'AA\n') self.assertEqual(parseString('AA').toprettyxml(), decl + '\n\tA\n\tA\n\n') self.assertEqual(parseString('AAA').toprettyxml(), - decl + '\n\tA\n\tA\n\tA\n\n') + decl + 'AAA\n') + # toprettyxml treats whitespace between elements as insignificant + self.assertEqual(parseString(' A ').toprettyxml(), + decl + '\n\t \n\tA\n\t \n\n') def test_toprettyxml_with_adjacent_text_nodes(self): - # see issue #4147, adjacent text nodes are indented normally + # see gh-81623, adjacent text nodes are not separated dom = Document() elem = dom.createElement('elem') elem.appendChild(dom.createTextNode('TEXT')) elem.appendChild(dom.createTextNode('TEXT')) dom.appendChild(elem) decl = '\n' - self.assertEqual(dom.toprettyxml(), - decl + '\n\tTEXT\n\tTEXT\n\n') + self.assertEqual(dom.toprettyxml(), decl + 'TEXTTEXT\n') + + def test_toprettyxml_preserve(self): + decl = '\n' + # xml:space="preserve" applies to the whole subtree + self.assertEqual( + parseString('AA' + ).toprettyxml(), + decl + 'AA\n') + self.assertEqual( + parseString('' + ).toprettyxml(), + decl + '\n') + # other values do not preserve whitespace + self.assertEqual( + parseString('A').toprettyxml(), + decl + '\n\tA\n\n') + + def test_toprettyxml_with_non_xml_whitespace(self): + # only " \t\r\n" are whitespace in XML (see XML 1.0, 2.3) + decl = '\n' + self.assertEqual(parseString('\xa0A').toprettyxml(), + decl + '\xa0A\n') + + def test_toprettyxml_with_dtd(self): + decl = '\n' + # only whitespace in element content is ignorable + doctype = ('' + ']>') + self.assertEqual( + parseString(doctype + 'AA').toprettyxml(), + decl + doctype + '\nAA\n') + doctype = ']>' + self.assertEqual( + parseString(doctype + 'AA').toprettyxml(), + decl + doctype + '\n\n\tA\n\tA\n\n') + + def test_toprettyxml_with_cdata_section(self): + decl = '\n' + self.assertEqual( + parseString('A').toprettyxml(), + decl + 'A\n') def test_toprettyxml_preserves_content_of_text_node(self): - # see issue #4147 + # see gh-48397 for str in ('A', 'C'): dom = parseString(str) dom2 = parseString(dom.toprettyxml()) @@ -642,6 +686,30 @@ def test_toprettyxml_preserves_content_of_text_node(self): dom.getElementsByTagName('B')[0].childNodes[0].toxml(), dom2.getElementsByTagName('B')[0].childNodes[0].toxml()) + def test_isWhitespaceInElementContent(self): + # only " \t\r\n" are whitespace in XML (see XML 1.0, 2.3) + dom = parseString(']>' + ' x\xa0') + children = dom.documentElement.childNodes + self.assertTrue(children[0].isWhitespaceInElementContent) + self.assertFalse(children[2].isWhitespaceInElementContent) + dom.unlink() + + def test_remove_whitespace_in_element_content(self): + from xml.dom.xmlbuilder import DOMBuilder, DOMInputSource + builder = DOMBuilder() + builder.setFeature("whitespace-in-element-content", False) + source = DOMInputSource() + source.byteStream = io.BytesIO( + b']>' + b' x\xc2\xa0') + dom = builder.parse(source) + children = dom.documentElement.childNodes + # ignorable whitespace is removed, other characters are not + self.assertEqual([node.nodeName for node in children], ['b', '#text']) + self.assertEqual(children[1].data, '\xa0') + dom.unlink() + def testProcessingInstruction(self): dom = parseString('') pi = dom.documentElement.firstChild diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index 2af2d1fd64520b..b4eba6a6bbeef7 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -773,9 +773,10 @@ def test_indent(self): ET.indent(elem) self.assertEqual(ET.tostring(elem), b'\n text\n') + # an element with mixed content is not indented elem = ET.XML("texttail") ET.indent(elem) - self.assertEqual(ET.tostring(elem), b'\n texttail') + self.assertEqual(ET.tostring(elem), b'texttail') elem = ET.XML("

par

\n

text

\t


") ET.indent(elem) @@ -845,6 +846,45 @@ def test_indent_space_caching(self): len({id(el.tail) for el in elem.iter()}), ) + def test_indent_non_xml_whitespace(self): + # only " \t\r\n" are whitespace in XML (see XML 1.0, 2.3) + elem = ET.XML('\xa0

text

\xa0') + ET.indent(elem) + self.assertEqual( + ET.tostring(elem), + b' 

text

 ' + ) + + def test_indent_preserve(self): + # xml:space="preserve" applies to the whole subtree + elem = ET.XML('

text

') + ET.indent(elem) + self.assertEqual( + ET.tostring(elem), + b'

text

' + ) + # other values do not preserve whitespace + elem = ET.XML('

text

') + ET.indent(elem) + self.assertEqual( + ET.tostring(elem), + b'\n' + b' \n' + b'

text

\n' + b' \n' + b'' + ) + + def test_indent_mixed_content(self): + # whitespace in an element which contains text is significant + elem = ET.XML('

hello x y

') + ET.indent(elem) + self.assertEqual(ET.tostring(elem), b'

hello x y

') + # the subtree of such element is not indented either + elem = ET.XML('

hello y

') + ET.indent(elem) + self.assertEqual(ET.tostring(elem), b'

hello y

') + def test_indent_level(self): elem = ET.XML("

pre
post

text

") with self.assertRaises(ValueError): @@ -4755,6 +4795,11 @@ def test_simple_roundtrip(self): xml = '' self.assertEqual(c14n_roundtrip(xml), xml) + def test_c14n_strip_non_xml_whitespace(self): + # only " \t\r\n" are whitespace in XML (see XML 1.0, 2.3) + self.assertEqual(c14n_roundtrip(" \xa0x\xa0 ", strip_text=True), + "\xa0x\xa0") + def test_c14n_exclusion(self): xml = textwrap.dedent("""\ diff --git a/Lib/xml/dom/expatbuilder.py b/Lib/xml/dom/expatbuilder.py index d56b2ddfdb2569..e3917c1cc68288 100644 --- a/Lib/xml/dom/expatbuilder.py +++ b/Lib/xml/dom/expatbuilder.py @@ -30,7 +30,8 @@ from xml.dom import xmlbuilder, minidom, Node from xml.dom import EMPTY_NAMESPACE, EMPTY_PREFIX, XMLNS_NAMESPACE from xml.parsers import expat -from xml.dom.minidom import _append_child, _set_attribute_node +from xml.dom.minidom import (_append_child, _set_attribute_node, + _XML_WHITESPACE) from xml.dom.NodeFilter import NodeFilter TEXT_NODE = Node.TEXT_NODE @@ -413,7 +414,8 @@ def _handle_white_text_nodes(self, node, info): # whitespace. L = [] for child in node.childNodes: - if child.nodeType == TEXT_NODE and not child.data.strip(): + if (child.nodeType == TEXT_NODE + and not child.data.strip(_XML_WHITESPACE)): L.append(child) # Remove ignorable whitespace from the tree. diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index 5fd3911bd3c9eb..93c2e0638493e3 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -31,6 +31,9 @@ _nodeTypes_with_children = (xml.dom.Node.ELEMENT_NODE, xml.dom.Node.ENTITY_REFERENCE_NODE) +# The white space characters of the XML specification (see XML 1.0, 2.3). +_XML_WHITESPACE = " \t\r\n" + class Node(xml.dom.Node): namespaceURI = None # this is non-null only for elements and attributes @@ -937,6 +940,10 @@ def writexml(self, writer, indent="", addindent="", newl=""): self.childNodes[0].nodeType in ( Node.TEXT_NODE, Node.CDATA_SECTION_NODE)): self.childNodes[0].writexml(writer, '', '', '') + elif self._preserves_whitespace(): + # Adding whitespace here would change the content. + for node in self.childNodes: + node.writexml(writer, '', '', '') else: writer.write(newl) for node in self.childNodes: @@ -946,6 +953,25 @@ def writexml(self, writer, indent="", addindent="", newl=""): else: writer.write("/>%s"%(newl)) + def _preserves_whitespace(self): + """Returns true iff whitespace in the content is significant. + + This is the case if the element is marked with xml:space="preserve", + if the DTD declares that its content model is not element content, + or, in absence of such declaration, if it contains text. + """ + if self.getAttribute("xml:space") == "preserve": + return True + doc = self.ownerDocument + info = doc and doc._get_elem_info(self) + if info is not None: + # Only whitespace in element content is ignorable + # (see XML 1.0, 3.2.1). + return not info.isElementContent() + return any(node.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE) + and node.data.strip(_XML_WHITESPACE) + for node in self.childNodes) + def _get_attributes(self): self._ensure_attributes() return NamedNodeMap(self._attrs, self._attrsNS, self) @@ -1209,7 +1235,7 @@ def replaceWholeText(self, content): return None def _get_isWhitespaceInElementContent(self): - if self.data.strip(): + if self.data.strip(_XML_WHITESPACE): return False elem = _get_containing_element(self) if elem is None: diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py index 951540eb9f45e9..07faa3e42aef49 100644 --- a/Lib/xml/etree/ElementTree.py +++ b/Lib/xml/etree/ElementTree.py @@ -101,6 +101,12 @@ from . import ElementPath +# The white space characters of the XML specification (see XML 1.0, 2.3). +_XML_WHITESPACE = " \t\r\n" + +# The xml:space attribute (see XML 1.0, 2.10). +_XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space" + class ParseError(SyntaxError): """An error when parsing an XML document. @@ -1181,7 +1187,20 @@ def indent(tree, space=" ", level=0): # Reduce the memory consumption by reusing indentation strings. indentations = ["\n" + level * space] + def _preserves_whitespace(elem): + # True iff whitespace in the content of the element is significant. + if elem.get(_XML_SPACE) == "preserve": + return True + if elem.text and elem.text.strip(_XML_WHITESPACE): + return True + return any(child.tail and child.tail.strip(_XML_WHITESPACE) + for child in elem) + def _indent_children(elem, level): + if _preserves_whitespace(elem): + # Adding whitespace here would change the content. + return + # Start a new indentation level for the first child. child_level = level + 1 try: @@ -1190,18 +1209,15 @@ def _indent_children(elem, level): child_indentation = indentations[level] + space indentations.append(child_indentation) - if not elem.text or not elem.text.strip(): - elem.text = child_indentation + elem.text = child_indentation for child in elem: if len(child): _indent_children(child, child_level) - if not child.tail or not child.tail.strip(): - child.tail = child_indentation + child.tail = child_indentation # Dedent after the last child by overwriting the previous indentation. - if not child.tail.strip(): - child.tail = indentations[level] + child.tail = indentations[level] _indent_children(tree, 0) @@ -1705,7 +1721,7 @@ def _default(self, text): if prefix == ">": self._doctype = None return - text = text.strip() + text = text.strip(_XML_WHITESPACE) if not text: return self._doctype.append(text) @@ -1921,7 +1937,7 @@ def _flush(self, _join_text=''.join): data = _join_text(self._data) del self._data[:] if self._strip_text and not self._preserve_space[-1]: - data = data.strip() + data = data.strip(_XML_WHITESPACE) if self._pending_start is not None: args, self._pending_start = self._pending_start, None qname_text = data if data and _looks_like_prefix_name(data) else None diff --git a/Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst b/Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst new file mode 100644 index 00000000000000..bdc02a4c55ee03 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst @@ -0,0 +1,6 @@ +:meth:`~xml.dom.minidom.Node.toprettyxml` in :mod:`xml.dom.minidom` and +:func:`~xml.etree.ElementTree.indent` in :mod:`xml.etree.ElementTree` no longer +add whitespace inside an element which is marked with ``xml:space="preserve"`` +or which contains text (:meth:`!toprettyxml` also takes into account the +content model declared in the DTD). Previously such indentation changed the +content of the element. diff --git a/Misc/NEWS.d/next/Library/2026-08-30-19-30-00.gh-issue-156658.Xq5Nt7.rst b/Misc/NEWS.d/next/Library/2026-08-30-19-30-00.gh-issue-156658.Xq5Nt7.rst new file mode 100644 index 00000000000000..3f2f105116c183 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-30-19-30-00.gh-issue-156658.Xq5Nt7.rst @@ -0,0 +1,5 @@ +:mod:`xml.dom` and :mod:`xml.etree.ElementTree` no longer treat characters +which are not white space in XML (such as U+00A0) as white space. Previously +they could be lost in :func:`~xml.etree.ElementTree.indent`, +:func:`~xml.etree.ElementTree.canonicalize` with ``strip_text=True``, and when +parsing with the ``whitespace-in-element-content`` feature turned off.