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.html;
16  
17  import static java.nio.charset.StandardCharsets.ISO_8859_1;
18  
19  import java.io.File;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.io.OutputStream;
23  import java.net.URL;
24  import java.nio.charset.Charset;
25  import java.nio.file.Files;
26  import java.util.HashMap;
27  import java.util.Map;
28  import java.util.regex.Pattern;
29  
30  import org.apache.commons.io.FileUtils;
31  import org.apache.commons.io.IOUtils;
32  import org.apache.commons.lang3.StringUtils;
33  import org.apache.commons.logging.Log;
34  import org.apache.commons.logging.LogFactory;
35  import org.htmlunit.Page;
36  import org.htmlunit.SgmlPage;
37  import org.htmlunit.WebResponse;
38  import org.htmlunit.util.MimeType;
39  
40  /**
41   * Utility to handle conversion from HTML code to XML string.
42   * @author Ahmed Ashour
43   * @author Ronald Brill
44   * @author Marc Guillemot
45   */
46  public class XmlSerializer {
47  
48      private static final String FILE_SEPARATOR = "/";
49      private static final Pattern CREATE_FILE_PATTERN = Pattern.compile(".*/");
50  
51      private static final Log LOG = LogFactory.getLog(XmlSerializer.class);
52  
53      private final StringBuilder builder_ = new StringBuilder();
54      private final StringBuilder indent_ = new StringBuilder();
55      private File outputDir_;
56  
57      /**
58       * Saves the specified page to the given file.
59       *
60       * @param page the page to save
61       * @param file the destination
62       * @throws IOException in case of error
63       */
64      public void save(final SgmlPage page, final File file) throws IOException {
65          save(page, file, false);
66      }
67  
68      private void save(final SgmlPage page, final File file, final boolean append) throws IOException {
69          String fileName = file.getName();
70  
71          if (!append) {
72              if (!fileName.endsWith(".htm") && !fileName.endsWith(".html")) {
73                  fileName += ".html";
74              }
75          }
76          final File outputFile = new File(file.getParentFile(), fileName);
77  
78          if (!append && outputFile.exists()) {
79              throw new IOException("File already exists: " + outputFile);
80          }
81          fileName = fileName.substring(0, fileName.lastIndexOf('.'));
82          outputDir_ = new File(file.getParentFile(), fileName);
83  
84          // don't use asXml here because we have to sync the encoding from the
85          // header with the one used by the writer
86          final DomElement node = page.getDocumentElement();
87          Charset charsetName = ISO_8859_1;
88          builder_.setLength(0);
89          indent_.setLength(0);
90          if (page.isHtmlPage()) {
91              charsetName = page.getCharset();
92              if (charsetName != null && node instanceof HtmlHtml) {
93                  builder_.append("<?xml version=\"1.0\" encoding=\"").append(charsetName).append("\"?>\n");
94              }
95          }
96          printXml(node);
97          final String response = builder_.toString();
98          builder_.setLength(0);
99          FileUtils.writeStringToFile(outputFile, response, charsetName, append);
100     }
101 
102     /**
103      * Returns the XML representation of the specified element.
104      *
105      * @param node a node
106      * @return the xml representation according to the setting of this serializer
107      * @throws IOException in case of problem saving resources
108      */
109     public String asXml(final DomElement node) throws IOException {
110         builder_.setLength(0);
111         indent_.setLength(0);
112         final SgmlPage page = node.getPage();
113         if (null != page && page.isHtmlPage()) {
114             final Charset charsetName = page.getCharset();
115             if (charsetName != null && node instanceof HtmlHtml) {
116                 builder_.append("<?xml version=\"1.0\" encoding=\"").append(charsetName).append("\"?>\n");
117             }
118         }
119         printXml(node);
120         final String response = builder_.toString();
121         builder_.setLength(0);
122         return response;
123     }
124 
125     protected void printXml(final DomElement node) throws IOException {
126         if (!isExcluded(node)) {
127             final boolean hasChildren = node.getFirstChild() != null;
128             builder_.append(indent_).append('<');
129             printOpeningTag(node);
130 
131             if (hasChildren || node.isEmptyXmlTagExpanded()) {
132                 builder_.append(">\n");
133                 for (DomNode child = node.getFirstChild(); child != null; child = child.getNextSibling()) {
134                     indent_.append("  ");
135                     if (child instanceof DomElement element) {
136                         printXml(element);
137                     }
138                     else {
139                         builder_.append(child);
140                     }
141                     indent_.setLength(indent_.length() - 2);
142                 }
143                 builder_.append(indent_).append("</").append(node.getTagName()).append(">\n");
144             }
145             else {
146                 builder_.append("/>\n");
147             }
148         }
149     }
150 
151     /**
152      * Returns the text content of the specified node.
153      *
154      * @param node a node
155      * @return the text representation according to the setting of this serializer
156      */
157     public String asText(final DomNode node) {
158         builder_.setLength(0);
159 
160         if (node instanceof DomText text) {
161             builder_.append(text.getData());
162         }
163         else {
164             printText(node);
165         }
166 
167         final String response = builder_.toString();
168         builder_.setLength(0);
169         return response;
170     }
171 
172     /**
173      * Prints the text content from this node and all children.
174      * @param node the node
175      */
176     protected void printText(final DomNode node) {
177         for (DomNode child = node.getFirstChild(); child != null; child = child.getNextSibling()) {
178             if (child instanceof DomText text) {
179                 builder_.append(text.getData());
180             }
181             else {
182                 printText(child);
183             }
184         }
185     }
186 
187     /**
188      * Prints the content between "&lt;" and "&gt;" (or "/&gt;") in the output of the tag name
189      * and its attributes in XML format.
190      * @param node the node whose opening tag is to be printed
191      * @throws IOException in case of problem saving resources
192      */
193     protected void printOpeningTag(final DomElement node) throws IOException {
194         builder_.append(node.getTagName());
195         final Map<String, DomAttr> attributes = readAttributes(node);
196 
197         for (final Map.Entry<String, DomAttr> entry : attributes.entrySet()) {
198             builder_.append(' ')
199                 .append(entry.getKey())
200                 .append("=\"");
201             final String value = entry.getValue().getNodeValue();
202             builder_.append(org.htmlunit.util.StringUtils.escapeXmlAttributeValue(value))
203                 .append('"');
204         }
205     }
206 
207     private Map<String, DomAttr> readAttributes(final DomElement node) throws IOException {
208         if (node instanceof HtmlImage image) {
209             return getAttributesFor(image);
210         }
211         else if (node instanceof HtmlLink link) {
212             return getAttributesFor(link);
213         }
214         else if (node instanceof BaseFrameElement element) {
215             return getAttributesFor(element);
216         }
217 
218         Map<String, DomAttr> attributes = node.getAttributesMap();
219         if (node instanceof HtmlOption option) {
220             attributes = new HashMap<>(attributes);
221             if (option.isSelected()) {
222                 if (!attributes.containsKey("selected")) {
223                     attributes.put("selected", new DomAttr(node.getPage(), null, "selected", "selected", false));
224                 }
225             }
226             else {
227                 attributes.remove("selected");
228             }
229         }
230         return attributes;
231     }
232 
233     /**
234      * Returns the attributes to serialize for the specified frame.
235      *
236      * @param frame the frame to get the attributes from
237      * @return the attribute map
238      */
239     private Map<String, DomAttr> getAttributesFor(final BaseFrameElement frame) throws IOException {
240         final Map<String, DomAttr> map = createAttributesCopyWithClonedAttribute(frame, DomElement.SRC_ATTRIBUTE);
241         final DomAttr srcAttr = map.get(DomElement.SRC_ATTRIBUTE);
242         if (srcAttr == null) {
243             return map;
244         }
245 
246         final Page enclosedPage = frame.getEnclosedPage();
247         final String suffix = getFileExtension(enclosedPage);
248         final File file = createFile(srcAttr.getValue(), "." + suffix);
249 
250         if (enclosedPage != null) {
251             if (enclosedPage.isHtmlPage()) {
252                 new XmlSerializer().save((HtmlPage) enclosedPage, file, true);
253             }
254             else {
255                 try (InputStream is = enclosedPage.getWebResponse().getContentAsStream()) {
256                     try (OutputStream fos = Files.newOutputStream(file.toPath())) {
257                         IOUtils.copyLarge(is, fos);
258                     }
259                 }
260             }
261         }
262 
263         srcAttr.setValue(file.getParentFile().getName() + FILE_SEPARATOR + file.getName());
264         return map;
265     }
266 
267     private static String getFileExtension(final Page enclosedPage) {
268         if (enclosedPage != null) {
269             if (enclosedPage.isHtmlPage()) {
270                 return "html";
271             }
272 
273             final URL url = enclosedPage.getUrl();
274             if (url.getPath().contains(".")) {
275                 return StringUtils.substringAfterLast(url.getPath(), ".");
276             }
277         }
278 
279         return ".unknown";
280     }
281 
282     /**
283      * Returns the attributes to serialize for the specified link.
284      *
285      * @param link the link to get the attributes from
286      * @return the attribute map
287      * @throws IOException in case of error
288      */
289     protected Map<String, DomAttr> getAttributesFor(final HtmlLink link) throws IOException {
290         final Map<String, DomAttr> map = createAttributesCopyWithClonedAttribute(link, "href");
291         final DomAttr hrefAttr = map.get("href");
292         if (hrefAttr != null && org.htmlunit.util.StringUtils.isNotBlank(hrefAttr.getValue())) {
293             final String protocol = link.getWebRequest().getUrl().getProtocol();
294             if ("http".equals(protocol) || "https".equals(protocol)) {
295                 try {
296                     final WebResponse response = link.getWebResponse(true, null, false, null);
297 
298                     final File file = createFile(hrefAttr.getValue(), ".css");
299                     FileUtils.writeStringToFile(file, response.getContentAsString(), ISO_8859_1);
300                     hrefAttr.setValue(outputDir_.getName() + FILE_SEPARATOR + file.getName());
301                 }
302                 catch (final IOException e) {
303                     LOG.error("XmlSerializer: IOException while downloading link content from url '"
304                                 + hrefAttr + "'", e);
305                 }
306                 catch (final IllegalStateException e) {
307                     LOG.error("XmlSerializer: IllegalStateException while downloading link content from url '"
308                                 + hrefAttr + "'", e);
309                 }
310             }
311         }
312 
313         return map;
314     }
315 
316     /**
317      * Returns the attributes to serialize for the specified image.
318      *
319      * @param image the image to get the attributes from
320      * @return the attribute map
321      */
322     protected Map<String, DomAttr> getAttributesFor(final HtmlImage image) {
323         final Map<String, DomAttr> map = createAttributesCopyWithClonedAttribute(image, DomElement.SRC_ATTRIBUTE);
324         final DomAttr srcAttr = map.get(DomElement.SRC_ATTRIBUTE);
325         if (srcAttr != null && org.htmlunit.util.StringUtils.isNotBlank(srcAttr.getValue())) {
326             try {
327                 final WebResponse response = image.getWebResponse(true);
328 
329                 try (InputStream inputStream = response.getContentAsStream()) {
330                     final File file = createFile(srcAttr.getValue(), "." + getSuffix(response));
331                     FileUtils.copyInputStreamToFile(inputStream, file);
332 
333                     final String valueOnFileSystem = outputDir_.getName() + FILE_SEPARATOR + file.getName();
334                     // this is the clone attribute node, not the original one of the page
335                     srcAttr.setValue(valueOnFileSystem);
336                 }
337             }
338             catch (final IOException e) {
339                 LOG.error("XmlSerializer: IOException while downloading image content from url '" + srcAttr + "'", e);
340             }
341             catch (final IllegalStateException e) {
342                 LOG.error("XmlSerializer: IllegalStateException while downloading image content from url '"
343                             + srcAttr + "'", e);
344             }
345         }
346 
347         return map;
348     }
349 
350     /**
351      * Returns the file extension for the specified page.
352      *
353      * @param enclosedPage the page
354      * @return the file extension
355      */
356     private static String getSuffix(final WebResponse response) {
357         // first try to take the one from the requested file
358         final String url = response.getWebRequest().getUrl().toString();
359         final String fileName =
360                 StringUtils.substringAfterLast(org.htmlunit.util.StringUtils.substringBefore(url, "?"), "/");
361         // if there is a suffix with 2-4 letters, the take it
362         final String suffix = StringUtils.substringAfterLast(fileName, ".");
363         if (suffix.length() > 1 && suffix.length() < 5) {
364             return suffix;
365         }
366 
367         // use content type
368         return MimeType.getFileExtension(response.getContentType());
369     }
370 
371     /**
372      * Creates a copy of the element's attributes with the specified attribute cloned.
373      *
374      * @param elt the element
375      * @param attrName the attribute to clone
376      * @return the copied attribute map
377      */
378     private static Map<String, DomAttr> createAttributesCopyWithClonedAttribute(final HtmlElement elt,
379             final String attrName) {
380         final Map<String, DomAttr> newMap = new HashMap<>(elt.getAttributesMap());
381 
382         // clone the specified element, if possible
383         final DomAttr attr = newMap.get(attrName);
384         if (null == attr) {
385             return newMap;
386         }
387 
388         final DomAttr clonedAttr = new DomAttr(attr.getPage(), attr.getNamespaceURI(),
389             attr.getQualifiedName(), attr.getValue(), attr.getSpecified());
390 
391         newMap.put(attrName, clonedAttr);
392 
393         return newMap;
394     }
395 
396     /**
397      * Determines whether the specified element should be excluded from serialization.
398      *
399      * @param element the element to check
400      * @return true if the element is a HtmlScript
401      */
402     protected boolean isExcluded(final DomElement element) {
403         return element instanceof HtmlScript;
404     }
405 
406     /**
407      * Computes the best file to save the response to the given URL.
408      * @param url the requested URL
409      * @param extension the preferred extension
410      * @return the file to create
411      * @throws IOException if a problem occurs creating the file
412      */
413     private File createFile(final String url, final String extension) throws IOException {
414         String name = url.replaceFirst("/$", "");
415         name = CREATE_FILE_PATTERN.matcher(name).replaceAll("");
416         name = org.htmlunit.util.StringUtils.substringBefore(name, "?"); // remove query
417         name = org.htmlunit.util.StringUtils.substringBefore(name, ";"); // remove additional info
418         name = StringUtils.substring(name, 0, 30); // many file systems have a limit at 255, let's limit it
419         name = org.htmlunit.util.StringUtils.sanitizeForFileName(name);
420         if (!name.endsWith(extension)) {
421             name += extension;
422         }
423         int counter = 0;
424         while (true) {
425             final String fileName;
426             if (counter == 0) {
427                 fileName = name;
428             }
429             else {
430                 fileName = StringUtils.substringBeforeLast(name, ".")
431                         + "_" + counter + "." + StringUtils.substringAfterLast(name, ".");
432             }
433             FileUtils.forceMkdir(outputDir_);
434             final File f = new File(outputDir_, fileName);
435             if (f.createNewFile()) {
436                 return f;
437             }
438             counter++;
439         }
440     }
441 }