View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.util;
16  
17  import static org.htmlunit.html.DomElement.ATTRIBUTE_NOT_DEFINED;
18  
19  import java.io.IOException;
20  import java.io.InputStream;
21  import java.io.InputStreamReader;
22  import java.io.Reader;
23  import java.io.StringReader;
24  import java.nio.charset.Charset;
25  import java.util.Locale;
26  
27  import javax.xml.parsers.DocumentBuilder;
28  import javax.xml.parsers.DocumentBuilderFactory;
29  import javax.xml.parsers.ParserConfigurationException;
30  
31  import org.apache.commons.io.input.BOMInputStream;
32  import org.apache.commons.logging.Log;
33  import org.apache.commons.logging.LogFactory;
34  import org.htmlunit.SgmlPage;
35  import org.htmlunit.WebResponse;
36  import org.htmlunit.html.DomAttr;
37  import org.htmlunit.html.DomCDataSection;
38  import org.htmlunit.html.DomComment;
39  import org.htmlunit.html.DomDocumentType;
40  import org.htmlunit.html.DomElement;
41  import org.htmlunit.html.DomNode;
42  import org.htmlunit.html.DomProcessingInstruction;
43  import org.htmlunit.html.DomText;
44  import org.htmlunit.html.ElementFactory;
45  import org.htmlunit.html.Html;
46  import org.htmlunit.xml.XmlPage;
47  import org.w3c.dom.Attr;
48  import org.w3c.dom.Document;
49  import org.w3c.dom.DocumentType;
50  import org.w3c.dom.NamedNodeMap;
51  import org.w3c.dom.Node;
52  import org.w3c.dom.NodeList;
53  import org.xml.sax.Attributes;
54  import org.xml.sax.ErrorHandler;
55  import org.xml.sax.InputSource;
56  import org.xml.sax.SAXException;
57  import org.xml.sax.SAXParseException;
58  import org.xml.sax.helpers.AttributesImpl;
59  
60  /**
61   * <span style="color:red">INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.</span><br>
62   *
63   * Provides utility methods for working with XML responses.
64   *
65   * @author Marc Guillemot
66   * @author Ahmed Ashour
67   * @author Sudhan Moghe
68   * @author Ronald Brill
69   * @author Chuck Dumont
70   * @author Frank Danek
71   */
72  public final class XmlUtils {
73  
74      private static final Log LOG = LogFactory.getLog(XmlUtils.class);
75  
76      private static final ErrorHandler DISCARD_MESSAGES_HANDLER = new ErrorHandler() {
77          /**
78           * Does nothing as we are not interested in this.
79           */
80          @Override
81          public void error(final SAXParseException exception) {
82              // Does nothing as we're not interested in this.
83          }
84  
85          /**
86           * Does nothing as we are not interested in this.
87           */
88          @Override
89          public void fatalError(final SAXParseException exception) {
90              // Does nothing as we're not interested in this.
91          }
92  
93          /**
94           * Does nothing as we are not interested in this.
95           */
96          @Override
97          public void warning(final SAXParseException exception) {
98              // Does nothing as we're not interested in this.
99          }
100     };
101 
102     /**
103      * Utility class, hide constructor.
104      */
105     private XmlUtils() {
106         // Empty.
107     }
108 
109     /**
110      * Builds a document from the content of the web response.
111      * A warning is logged if an exception is thrown while parsing the XML content
112      * (for instance when the content is not valid XML and cannot be parsed).
113      *
114      * @param webResponse the response from the server
115      * @return the parse result
116      * @throws IOException if the page could not be created
117      * @throws SAXException if parsing fails
118      * @throws ParserConfigurationException if a {@link DocumentBuilder} cannot be created
119      */
120     public static Document buildDocument(final WebResponse webResponse)
121         throws IOException, SAXException, ParserConfigurationException {
122 
123         final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
124 
125         if (webResponse == null) {
126             return factory.newDocumentBuilder().newDocument();
127         }
128 
129         factory.setNamespaceAware(true);
130 
131         Charset charset = webResponse.getContentCharset();
132         try (InputStream is = webResponse.getContentAsStreamWithBomIfApplicable()) {
133             if (is instanceof BOMInputStream stream) {
134                 final String bomCharsetName = stream.getBOMCharsetName();
135                 if (bomCharsetName != null) {
136                     charset = Charset.forName(bomCharsetName);
137                 }
138             }
139 
140             try (InputStreamReader reader = new InputStreamReader(is, charset)) {
141                 // we have to do the blank input check and the parsing in one step
142                 final TrackBlankContentAndSkipLeadingWhitespaceReader tracker
143                         = new TrackBlankContentAndSkipLeadingWhitespaceReader(reader);
144 
145                 final InputSource source = new InputSource(tracker);
146                 final DocumentBuilder builder = factory.newDocumentBuilder();
147                 builder.setErrorHandler(DISCARD_MESSAGES_HANDLER);
148                 builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader("")));
149                 try {
150                     // this closes the input source/stream
151                     return builder.parse(source);
152                 }
153                 catch (final SAXException e) {
154                     if (tracker.wasBlank()) {
155                         return factory.newDocumentBuilder().newDocument();
156                     }
157                     throw e;
158                 }
159             }
160         }
161     }
162 
163     /**
164      * Helper for memory and performance optimization.
165      */
166     private static final class TrackBlankContentAndSkipLeadingWhitespaceReader extends Reader {
167         private final Reader reader_;
168         private boolean wasBlank_ = true;
169 
170         TrackBlankContentAndSkipLeadingWhitespaceReader(final Reader characterStream) {
171             super();
172             reader_ = characterStream;
173         }
174 
175         public boolean wasBlank() {
176             return wasBlank_;
177         }
178 
179         @Override
180         public void close() throws IOException {
181             reader_.close();
182         }
183 
184         @Override
185         public int read(final char[] cbuf, final int off, final int len) throws IOException {
186             int readLength = reader_.read(cbuf, off, len);
187 
188             if (wasBlank_ && readLength > -1) {
189                 for (int i = 0; i < readLength; i++) {
190                     final char ch = cbuf[off + i];
191                     if (!Character.isWhitespace(ch)) {
192                         wasBlank_ = false;
193                         if (i > 0) {
194                             // skipt the leading whitespace
195                             readLength -= i;
196                             System.arraycopy(cbuf, off + i, cbuf, off, readLength);
197                         }
198                         break;
199                     }
200                 }
201             }
202             return readLength;
203         }
204     }
205 
206     /**
207      * Recursively appends a {@link Node} child to a {@link DomNode} parent.
208      *
209      * @param page the owner page of {@link DomElement}s to be created
210      * @param parent the parent {@link DomNode}
211      * @param child the child {@link Node} to append
212      * @param handleXHTMLAsHTML if {@code true}, elements from the XHTML namespace are handled as HTML elements
213      *     instead of DOM elements
214      */
215     public static void appendChild(final SgmlPage page, final DomNode parent, final Node child,
216         final boolean handleXHTMLAsHTML) {
217         final DocumentType documentType = child.getOwnerDocument().getDoctype();
218         if (documentType != null && page instanceof XmlPage xmlPage) {
219             final DomDocumentType domDoctype = new DomDocumentType(
220                     page, documentType.getName(), documentType.getPublicId(), documentType.getSystemId());
221             xmlPage.setDocumentType(domDoctype);
222         }
223         final DomNode childXml = createFrom(page, child, handleXHTMLAsHTML);
224         parent.appendChild(childXml);
225         copy(page, child, childXml, handleXHTMLAsHTML);
226     }
227 
228     private static DomNode createFrom(final SgmlPage page, final Node source, final boolean handleXHTMLAsHTML) {
229         if (source.getNodeType() == Node.TEXT_NODE) {
230             return new DomText(page, source.getNodeValue());
231         }
232         if (source.getNodeType() == Node.PROCESSING_INSTRUCTION_NODE) {
233             return new DomProcessingInstruction(page, source.getNodeName(), source.getNodeValue());
234         }
235         if (source.getNodeType() == Node.COMMENT_NODE) {
236             return new DomComment(page, source.getNodeValue());
237         }
238         if (source.getNodeType() == Node.DOCUMENT_TYPE_NODE) {
239             final DocumentType documentType = (DocumentType) source;
240             return new DomDocumentType(page, documentType.getName(), documentType.getPublicId(),
241                     documentType.getSystemId());
242         }
243         final String ns = source.getNamespaceURI();
244         String localName = source.getLocalName();
245         if (handleXHTMLAsHTML && Html.XHTML_NAMESPACE.equals(ns)) {
246             final ElementFactory factory = page.getWebClient().getPageCreator().getHtmlParser().getFactory(localName);
247             return factory.createElementNS(page, ns, localName,
248                     namedNodeMapToSaxAttributes(source.getAttributes()));
249         }
250         final NamedNodeMap nodeAttributes = source.getAttributes();
251         if (page != null && page.isHtmlPage()) {
252             localName = localName.toUpperCase(Locale.ROOT);
253         }
254         final String qualifiedName;
255         if (source.getPrefix() == null) {
256             qualifiedName = localName;
257         }
258         else {
259             qualifiedName = source.getPrefix() + ':' + localName;
260         }
261 
262         final String namespaceURI = source.getNamespaceURI();
263         if (Html.SVG_NAMESPACE.equals(namespaceURI)) {
264             return page.getWebClient().getPageCreator().getHtmlParser().getSvgFactory()
265                     .createElementNS(page, namespaceURI, qualifiedName,
266                             namedNodeMapToSaxAttributes(nodeAttributes));
267         }
268 
269         final OrderedFastHashMap<String, DomAttr> attributes = new OrderedFastHashMap<>();
270         for (int i = 0; i < nodeAttributes.getLength(); i++) {
271             final Attr attribute = (Attr) nodeAttributes.item(i);
272             final String attributeNamespaceURI = attribute.getNamespaceURI();
273             final String attributeQualifiedName;
274             if (attribute.getPrefix() == null) {
275                 attributeQualifiedName = attribute.getLocalName();
276             }
277             else {
278                 attributeQualifiedName = attribute.getPrefix() + ':' + attribute.getLocalName();
279             }
280             final String value = attribute.getNodeValue();
281             final boolean specified = attribute.getSpecified();
282             final DomAttr xmlAttribute =
283                     new DomAttr(page, attributeNamespaceURI, attributeQualifiedName, value, specified);
284             attributes.put(attribute.getNodeName(), xmlAttribute);
285         }
286         return new DomElement(namespaceURI, qualifiedName, page, attributes);
287     }
288 
289     private static Attributes namedNodeMapToSaxAttributes(final NamedNodeMap attributesMap) {
290         final AttributesImpl attributes = new AttributesImpl();
291         final int length = attributesMap.getLength();
292         for (int i = 0; i < length; i++) {
293             final Node attr = attributesMap.item(i);
294             attributes.addAttribute(attr.getNamespaceURI(), attr.getLocalName(),
295                 attr.getNodeName(), null, attr.getNodeValue());
296         }
297 
298         return attributes;
299     }
300 
301     /**
302      * Copies all children from {@code source} to {@code dest} within the context of the specified page.
303      *
304      * @param page the page to which the nodes belong
305      * @param source the node to copy from
306      * @param dest the node to copy to
307      * @param handleXHTMLAsHTML if {@code true}, elements from the XHTML namespace are handled as HTML elements
308      */
309     private static void copy(final SgmlPage page, final Node source, final DomNode dest,
310         final boolean handleXHTMLAsHTML) {
311         final NodeList nodeChildren = source.getChildNodes();
312         for (int i = 0; i < nodeChildren.getLength(); i++) {
313             final Node child = nodeChildren.item(i);
314             switch (child.getNodeType()) {
315                 case Node.ELEMENT_NODE:
316                     final DomNode childXml = createFrom(page, child, handleXHTMLAsHTML);
317                     dest.appendChild(childXml);
318                     copy(page, child, childXml, handleXHTMLAsHTML);
319                     break;
320 
321                 case Node.TEXT_NODE:
322                     dest.appendChild(new DomText(page, child.getNodeValue()));
323                     break;
324 
325                 case Node.CDATA_SECTION_NODE:
326                     dest.appendChild(new DomCDataSection(page, child.getNodeValue()));
327                     break;
328 
329                 case Node.COMMENT_NODE:
330                     dest.appendChild(new DomComment(page, child.getNodeValue()));
331                     break;
332 
333                 case Node.PROCESSING_INSTRUCTION_NODE:
334                     dest.appendChild(new DomProcessingInstruction(page, child.getNodeName(), child.getNodeValue()));
335                     break;
336 
337                 default:
338                     if (LOG.isWarnEnabled()) {
339                         LOG.warn("NodeType " + child.getNodeType()
340                             + " (" + child.getNodeName() + ") is not yet supported.");
341                     }
342             }
343         }
344     }
345 
346     /**
347      * Searches for the namespace URI bound to the given prefix, starting from the specified element.
348      * The default namespace can be searched for by passing an empty string as the prefix.
349      *
350      * @param element the element to start searching from
351      * @param prefix the namespace prefix to look up; use {@code ""} for the default namespace
352      * @return the namespace URI bound to the prefix, or {@code null} if none is found
353      */
354     public static String lookupNamespaceURI(final DomElement element, final String prefix) {
355         final String uri = prefix.isEmpty()
356                 ? element.getAttributeDirect("xmlns")
357                 : element.getAttribute("xmlns:" + prefix);
358 
359         if (ATTRIBUTE_NOT_DEFINED != uri) {
360             return uri;
361         }
362 
363         final DomNode parentNode = element.getParentNode();
364         if (parentNode instanceof DomElement domElement) {
365             return lookupNamespaceURI(domElement, prefix);
366         }
367 
368         return null;
369     }
370 }