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.javascript.host.xml;
16  
17  import static org.htmlunit.BrowserVersionFeatures.JS_XSLT_TRANSFORM_INDENT;
18  
19  import java.io.ByteArrayOutputStream;
20  import java.nio.charset.Charset;
21  import java.nio.charset.StandardCharsets;
22  import java.util.Collections;
23  import java.util.HashMap;
24  import java.util.Map;
25  
26  import javax.xml.XMLConstants;
27  import javax.xml.parsers.DocumentBuilderFactory;
28  import javax.xml.transform.OutputKeys;
29  import javax.xml.transform.Source;
30  import javax.xml.transform.Transformer;
31  import javax.xml.transform.TransformerFactory;
32  import javax.xml.transform.dom.DOMResult;
33  import javax.xml.transform.dom.DOMSource;
34  import javax.xml.transform.stream.StreamResult;
35  
36  import org.htmlunit.SgmlPage;
37  import org.htmlunit.WebResponse;
38  import org.htmlunit.WebResponseData;
39  import org.htmlunit.html.DomDocumentFragment;
40  import org.htmlunit.html.DomNode;
41  import org.htmlunit.html.DomText;
42  import org.htmlunit.http.HttpStatus;
43  import org.htmlunit.javascript.HtmlUnitScriptable;
44  import org.htmlunit.javascript.JavaScriptEngine;
45  import org.htmlunit.javascript.configuration.JsxClass;
46  import org.htmlunit.javascript.configuration.JsxConstructor;
47  import org.htmlunit.javascript.configuration.JsxFunction;
48  import org.htmlunit.javascript.host.dom.Document;
49  import org.htmlunit.javascript.host.dom.DocumentFragment;
50  import org.htmlunit.javascript.host.dom.Node;
51  import org.htmlunit.util.EncodingSniffer;
52  import org.htmlunit.util.XmlUtils;
53  import org.htmlunit.xml.XmlPage;
54  import org.w3c.dom.NodeList;
55  
56  /**
57   * JavaScript host object for {@code XSLTProcessor}.
58   *
59   * @author Ahmed Ashour
60   * @author Ronald Brill
61   * @author MatrixNeoKozak (matrixneo2026@tutamail.com)
62   *
63   * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/XSLTProcessor">MDN Documentation</a>
64   */
65  @JsxClass
66  public class XSLTProcessor extends HtmlUnitScriptable {
67  
68      private Node style_;
69      private final Map<String, Object> parameters_ = new HashMap<>();
70  
71      /**
72       * Creates an instance of this object.
73       */
74      @JsxConstructor
75      public void jsConstructor() {
76          // nothing to do
77      }
78  
79      /**
80       * Imports the specified stylesheet into this {@code XSLTProcessor} for transformations.
81       * The specified node may be either a document node or an element node. If it is a document node,
82       * then the document can contain either an XSLT stylesheet or an LRE stylesheet. If it is an element
83       * node, it must be the {@code xsl:stylesheet} (or {@code xsl:transform}) element of an XSLT stylesheet.
84       *
85       * @param style the root node of an XSLT stylesheet (may be a document node or an element node)
86       */
87      @JsxFunction
88      public void importStylesheet(final Node style) {
89          style_ = style;
90      }
91  
92      /**
93       * Transforms the given source node by applying the stylesheet imported via {@link #importStylesheet}.
94       * The owner document of the output node owns the returned document.
95       *
96       * @param source the node to be transformed
97       * @return the transformation result as an {@link XMLDocument}
98       */
99      @JsxFunction
100     public XMLDocument transformToDocument(final Node source) {
101         final XMLDocument doc = new XMLDocument();
102         doc.setPrototype(getPrototype(doc.getClass()));
103         doc.setParentScope(getParentScope());
104 
105         final Object transformResult = transform(source);
106         final org.w3c.dom.Node node;
107         if (transformResult instanceof org.w3c.dom.Node transformedDoc) {
108             node = transformedDoc.getFirstChild();
109         }
110         else {
111             node = null;
112         }
113         final XmlPage page = new XmlPage(node, getWindow().getWebWindow());
114         doc.setDomNode(page);
115         return doc;
116     }
117 
118     /**
119      * Performs the XSLT transformation and returns either a {@link Node} or a {@link String}.
120      *
121      * @param source the node to transform
122      * @return the transformation result
123      */
124     private Object transform(final Node source) {
125         try {
126             final DomNode sourceDomNode = source.getDomNodeOrDie();
127             Source xmlSource = new DOMSource(sourceDomNode);
128 
129             final DomNode xsltDomNode = style_.getDomNodeOrDie();
130             final Source xsltSource = new DOMSource(xsltDomNode);
131 
132             final TransformerFactory transformerFactory = TransformerFactory.newInstance();
133 
134             // By default, the JDK turns on FSP for DOM and SAX parsers and XML schema validators,
135             // which sets a number of processing limits on the processors. Conversely, by default,
136             // the JDK turns off FSP for transformers and XPath, which enables extension functions for XSLT and XPath.
137             transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
138             try {
139                 transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
140             }
141             catch (final IllegalArgumentException ignored) {
142                 // ignore
143             }
144             try {
145                 transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
146             }
147             catch (final IllegalArgumentException ignored) {
148                 // ignore
149             }
150 
151             final SgmlPage page = sourceDomNode.getPage();
152             if (page != null && page.getWebClient().getBrowserVersion()
153                                             .hasFeature(JS_XSLT_TRANSFORM_INDENT)) {
154                 final DomNode outputNode = findOutputNode(xsltDomNode);
155                 if (outputNode != null) {
156                     final org.w3c.dom.Node indentNode = outputNode.getAttributes().getNamedItem("indent");
157                     if (indentNode != null && "yes".equalsIgnoreCase(indentNode.getNodeValue())) {
158                         try {
159                             transformerFactory.setAttribute("indent-number", Integer.valueOf(2));
160                         }
161                         catch (final IllegalArgumentException ignored) {
162                             // ignore
163                         }
164                         final Transformer transformer = transformerFactory.newTransformer(xsltSource);
165                         transformer.setOutputProperty(OutputKeys.INDENT, "yes");
166                         try {
167                             transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
168                         }
169                         catch (final IllegalArgumentException ignored) {
170                             // ignore
171                         }
172 
173                         for (final Map.Entry<String, Object> entry : parameters_.entrySet()) {
174                             transformer.setParameter(entry.getKey(), entry.getValue());
175                         }
176 
177                         // hack to preserve indention
178                         // the transformer only accepts the OutputKeys.INDENT setting if
179                         // the StreamResult is used
180                         try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
181                             transformer.transform(xmlSource, new StreamResult(out));
182                             final WebResponseData data = new WebResponseData(out.toByteArray(),
183                                     HttpStatus.OK_200, HttpStatus.OK_200_MSG, Collections.emptyList());
184                             final WebResponse response = new WebResponse(data, null, 0) {
185 
186                                 // XmlUtils.buildDocument reads the out stream using the contentCharset
187                                 // we have to provide the correct one
188                                 @Override
189                                 public Charset getContentCharset() {
190                                     final Charset cs = EncodingSniffer.toCharset(
191                                             transformer.getOutputProperty(OutputKeys.ENCODING));
192                                     if (cs == null) {
193                                         return StandardCharsets.UTF_8;
194                                     }
195                                     return cs;
196                                 }
197                             };
198                             return XmlUtils.buildDocument(response);
199                         }
200                     }
201                 }
202             }
203 
204             final Transformer transformer = transformerFactory.newTransformer(xsltSource);
205             transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
206 
207             for (final Map.Entry<String, Object> entry : parameters_.entrySet()) {
208                 transformer.setParameter(entry.getKey(), entry.getValue());
209             }
210 
211             final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
212             try {
213                 factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
214             }
215             catch (final javax.xml.parsers.ParserConfigurationException ignored) {
216                 // ignore
217             }
218             final org.w3c.dom.Document containerDocument = factory.newDocumentBuilder().newDocument();
219             final org.w3c.dom.Element containerElement = containerDocument.createElement("container");
220             containerDocument.appendChild(containerElement);
221 
222             final DOMResult result = new DOMResult(containerElement);
223             transformer.transform(xmlSource, result);
224 
225             final org.w3c.dom.Node transformedNode = result.getNode();
226             final org.w3c.dom.Node transformedFirstChild = transformedNode.getFirstChild();
227             if (transformedFirstChild != null && transformedFirstChild.getNodeType() == Node.ELEMENT_NODE) {
228                 return transformedNode;
229             }
230 
231             // output is not DOM (text)
232             xmlSource = new DOMSource(source.getDomNodeOrDie());
233             try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
234                 transformer.transform(xmlSource, new StreamResult(out));
235 
236                 final Charset cs = EncodingSniffer.toCharset(transformer.getOutputProperty(OutputKeys.ENCODING));
237                 if (cs == null) {
238                     return new String(out.toByteArray(), StandardCharsets.UTF_8);
239                 }
240                 return new String(out.toByteArray(), cs);
241             }
242         }
243         catch (final RuntimeException e) {
244             throw e;
245         }
246         catch (final Exception e) {
247             throw JavaScriptEngine.reportRuntimeError("Exception: " + e);
248         }
249     }
250 
251     /**
252      * Transforms the given source node by applying the imported stylesheet and appends
253      * the result as children of the given output document fragment.
254      *
255      * @param source the node to be transformed
256      * @param output the {@link Document} whose owner document is used to generate the output
257      * @return the transformation result as a {@link DocumentFragment}
258      */
259     @JsxFunction
260     public DocumentFragment transformToFragment(final Node source, final Object output) {
261         final SgmlPage page = (SgmlPage) ((Document) output).getDomNodeOrDie();
262 
263         final DomDocumentFragment fragment = page.createDocumentFragment();
264         final DocumentFragment rv = new DocumentFragment();
265         rv.setPrototype(getPrototype(rv.getClass()));
266         rv.setParentScope(getParentScope());
267         rv.setDomNode(fragment);
268 
269         final Object result = transform(source);
270         if (result instanceof org.w3c.dom.Node node) {
271             final SgmlPage parentPage = fragment.getPage();
272             final NodeList children = node.getChildNodes();
273             final int length = children.getLength();
274             for (int i = 0; i < length; i++) {
275                 XmlUtils.appendChild(parentPage, fragment, children.item(i), true);
276             }
277         }
278         else {
279             final DomText text = new DomText(fragment.getPage(), (String) result);
280             fragment.appendChild(text);
281         }
282 
283         return rv;
284     }
285 
286     /**
287      * Sets a parameter to be used in subsequent transformations with this {@code XSLTProcessor}.
288      * If the parameter does not exist in the stylesheet it will be ignored.
289      *
290      * @param namespaceURI the namespace URI of the XSLT parameter
291      * @param localName the local name of the XSLT parameter
292      * @param value the new value of the XSLT parameter
293      */
294     @JsxFunction
295     public void setParameter(final String namespaceURI, final String localName, final Object value) {
296         parameters_.put(getQualifiedName(namespaceURI, localName), value);
297     }
298 
299     /**
300      * Returns a parameter previously set by {@link #setParameter}, or {@code null} if not set.
301      *
302      * @param namespaceURI the namespace URI of the XSLT parameter
303      * @param localName the local name of the XSLT parameter
304      * @return the value of the XSLT parameter, or {@code null} if not found
305      */
306     @JsxFunction
307     public Object getParameter(final String namespaceURI, final String localName) {
308         return parameters_.get(getQualifiedName(namespaceURI, localName));
309     }
310 
311     private static String getQualifiedName(final String namespaceURI, final String localName) {
312         final String qualifiedName;
313         if (namespaceURI != null && !namespaceURI.isEmpty() && !"null".equals(namespaceURI)) {
314             qualifiedName = '{' + namespaceURI + '}' + localName;
315         }
316         else {
317             qualifiedName = localName;
318         }
319         return qualifiedName;
320     }
321 
322     private static DomNode findOutputNode(final DomNode xsltDomNode) {
323         for (final DomNode child : xsltDomNode.getChildren()) {
324             if ("output".equals(child.getLocalName())) {
325                 return child;
326             }
327 
328             for (final DomNode child1 : child.getChildren()) {
329                 if ("output".equals(child1.getLocalName())) {
330                     return child1;
331                 }
332             }
333         }
334         return null;
335     }
336 }