View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2012-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.jcabi.xml;
6   
7   import com.jcabi.log.Logger;
8   import java.io.ByteArrayOutputStream;
9   import java.io.File;
10  import java.io.FileNotFoundException;
11  import java.io.IOException;
12  import java.io.InputStream;
13  import java.io.StringReader;
14  import java.io.UnsupportedEncodingException;
15  import java.net.URI;
16  import java.net.URL;
17  import java.nio.charset.StandardCharsets;
18  import java.nio.file.Path;
19  import java.util.HashMap;
20  import java.util.Map;
21  import javax.xml.parsers.DocumentBuilder;
22  import javax.xml.parsers.DocumentBuilderFactory;
23  import javax.xml.parsers.ParserConfigurationException;
24  import javax.xml.transform.Result;
25  import javax.xml.transform.Templates;
26  import javax.xml.transform.Transformer;
27  import javax.xml.transform.TransformerConfigurationException;
28  import javax.xml.transform.TransformerException;
29  import javax.xml.transform.TransformerFactory;
30  import javax.xml.transform.dom.DOMResult;
31  import javax.xml.transform.dom.DOMSource;
32  import javax.xml.transform.stream.StreamResult;
33  import javax.xml.transform.stream.StreamSource;
34  import lombok.EqualsAndHashCode;
35  import org.cactoos.map.MapEntry;
36  import org.cactoos.map.MapOf;
37  import org.cactoos.scalar.Sticky;
38  import org.cactoos.scalar.Synced;
39  import org.cactoos.scalar.Unchecked;
40  import org.w3c.dom.Document;
41  
42  /**
43   * Implementation of {@link XSL}.
44   *
45   * <p>Objects of this class are immutable and thread-safe.
46   *
47   * @since 0.4
48   * @checkstyle ClassDataAbstractionCouplingCheck (500 lines)
49   * @checkstyle AbbreviationAsWordInNameCheck (5 lines)
50   * @checkstyle ClassFanOutComplexityCheck (500 lines)
51   */
52  @EqualsAndHashCode(of = "xsl")
53  @SuppressWarnings("PMD.TooManyMethods")
54  public final class XSLDocument implements XSL {
55  
56      /**
57       * Strips spaces of whitespace-only text nodes.
58       *
59       * <p>This will NOT remove
60       * existing indentation between Element nodes currently introduced by the
61       * constructor of {@link XMLDocument}. For example:
62       *
63       * <pre>
64       * {@code
65       * &lt;a&gt;
66       *           &lt;b> TXT &lt;/b>
67       *    &lt;/a>}
68       * </pre>
69       *
70       * becomes
71       *
72       * <pre>
73       * {@code
74       * &lt;a>
75       *     &lt;b> TXT &lt;/b>
76       * &lt;/a>}
77       * </pre>
78       *
79       * @since 0.14
80       */
81      public static final XSL STRIP = XSLDocument.make(
82          XSL.class.getResourceAsStream("strip.xsl")
83      );
84  
85      /**
86       * Per-thread builder — {@link DocumentBuilder} is not thread-safe, so each
87       * thread keeps its own instance. The factory and builder are created lazily
88       * on the first {@link #transform} call in each thread, avoiding any risk of
89       * circular class-initialization when the factory's ServiceLoader scan runs.
90       */
91      private static final ThreadLocal<DocumentBuilder> DBUILDER =
92          ThreadLocal.withInitial(
93              () -> {
94                  try {
95                      return DocumentBuilderFactory.newInstance().newDocumentBuilder();
96                  } catch (final ParserConfigurationException ex) {
97                      throw new IllegalStateException(
98                          "Failed to create DocumentBuilder", ex
99                      );
100                 }
101             }
102         );
103 
104     /**
105      * XSL document.
106      */
107     private final transient String xsl;
108 
109     /**
110      * Sources.
111      */
112     private final transient Sources sources;
113 
114     /**
115      * Parameters.
116      */
117     private final transient Map<String, Object> params;
118 
119     /**
120      * System ID (base).
121      * @since 0.20
122      */
123     private final transient String sid;
124 
125     /**
126      * Compiled stylesheet, cached on first use.
127      */
128     private final transient Unchecked<Templates> templates;
129 
130     /**
131      * Formatted (pretty-printed) string form, cached on first use.
132      * Since {@code xsl} is immutable the result never changes.
133      */
134     private final transient Unchecked<String> formatted;
135 
136     /**
137      * Public ctor, from XML as a source.
138      * @param src XSL document body
139      */
140     public XSLDocument(final XML src) {
141         this(src, "/");
142     }
143 
144     /**
145      * Public ctor, from XML as a source.
146      * @param src XSL document body
147      * @param base SystemId/Base
148      * @since 0.20
149      */
150     public XSLDocument(final XML src, final String base) {
151         this(src.toString(), base);
152     }
153 
154     /**
155      * Public ctor, from URL.
156      * @param url Location of document
157      * @throws IOException If fails to read
158      * @since 0.7.4
159      */
160     public XSLDocument(final URL url) throws IOException {
161         this(url, url.toString());
162     }
163 
164     /**
165      * Public ctor, from URL with alternative SystemId.
166      * @param url Location of document
167      * @param base SystemId/Base
168      * @throws IOException If fails to read
169      * @since 0.26.0
170      */
171     public XSLDocument(final URL url, final String base) throws IOException {
172         this(new TextResource(url).toString(), base);
173     }
174 
175     /**
176      * Public ctor, from file.
177      * @param file Location of document
178      * @throws FileNotFoundException If fails to read
179      * @since 0.21
180      */
181     public XSLDocument(final File file) throws IOException {
182         this(file, file.getAbsolutePath());
183     }
184 
185     /**
186      * Public ctor, from file  with alternative SystemId.
187      * @param file Location of document
188      * @param base SystemId/Base
189      * @since 0.26.0
190      */
191     public XSLDocument(final File file, final String base) throws IOException {
192         this(new TextResource(file).toString(), base);
193     }
194 
195     /**
196      * Public ctor, from file.
197      * @param file Location of document
198      * @throws FileNotFoundException If fails to read
199      * @since 0.21
200      */
201     public XSLDocument(final Path file) throws IOException {
202         this(file.toFile());
203     }
204 
205     /**
206      * Public ctor, from file with custom SystemId.
207      * @param file Location of document
208      * @param base SystemId/Base
209      * @throws FileNotFoundException If fails to read
210      * @since 0.26.0
211      */
212     public XSLDocument(final Path file, final String base)
213         throws IOException {
214         this(file.toFile(), base);
215     }
216 
217     /**
218      * Public ctor, from URI.
219      * @param uri Location of document
220      * @throws IOException If fails to read
221      * @since 0.15
222      */
223     public XSLDocument(final URI uri) throws IOException {
224         this(uri, uri.toString());
225     }
226 
227     /**
228      * Public ctor, from URI.
229      * @param uri Location of document
230      * @param base SystemId/Base
231      * @throws IOException If fails to read
232      * @since 0.26.0
233      */
234     public XSLDocument(final URI uri, final String base) throws IOException {
235         this(new TextResource(uri).toString(), base);
236     }
237 
238     /**
239      * Public ctor, from XSL as an input stream.
240      * @param stream XSL input stream
241      */
242     public XSLDocument(final InputStream stream) {
243         this(new TextResource(stream).toString());
244     }
245 
246     /**
247      * Public ctor, from XSL as an input stream.
248      * @param stream XSL input stream
249      * @param base SystemId/Base
250      * @since 0.20
251      */
252     public XSLDocument(final InputStream stream, final String base) {
253         this(new TextResource(stream).toString(), base);
254     }
255 
256     /**
257      * Public ctor, from XSL as a string.
258      * @param src XML document body
259      */
260     public XSLDocument(final String src) {
261         this(src, Sources.DUMMY);
262     }
263 
264     /**
265      * Public ctor, from XSL as a string.
266      * @param src XML document body
267      * @param base SystemId/Base
268      * @since 0.20
269      */
270     public XSLDocument(final String src, final String base) {
271         this(src, Sources.DUMMY, base);
272     }
273 
274     /**
275      * Public ctor, from XSL as a string.
276      * @param src XML document body
277      * @param srcs Sources
278      * @since 0.9
279      */
280     public XSLDocument(final String src, final Sources srcs) {
281         this(src, srcs, new HashMap<>(0));
282     }
283 
284     /**
285      * Public ctor, from XSL as a string.
286      * @param src XML document body
287      * @param srcs Sources
288      * @param base SystemId/Base
289      * @since 0.20
290      */
291     public XSLDocument(final String src, final Sources srcs,
292         final String base) {
293         this(src, srcs, new HashMap<>(0), base);
294     }
295 
296     /**
297      * Public ctor, from XSL as a string.
298      * @param src XML document body
299      * @param srcs Sources
300      * @param map Map of XSL params
301      * @since 0.16
302      */
303     public XSLDocument(final String src, final Sources srcs,
304         final Map<String, Object> map) {
305         this(src, srcs, map, "/");
306     }
307 
308     /**
309      * Public ctor, from XSL as a string.
310      * @param src XML document body
311      * @param srcs Sources
312      * @param map Map of XSL params
313      * @param base SystemId/Base
314      * @since 0.20
315      * @checkstyle ParameterNumberCheck (5 lines)
316      */
317     public XSLDocument(final String src, final Sources srcs,
318         final Map<String, Object> map, final String base) {
319         this(
320             src, srcs, new HashMap<>(map), base,
321             XSLDocument.load(srcs, src, base),
322             XSLDocument.format(src)
323         );
324     }
325 
326     /**
327      * Private ctor that carries a pre-compiled stylesheet into a new instance.
328      * @param src XSL document body
329      * @param srcs Sources
330      * @param map Map of XSL params
331      * @param base SystemId/Base
332      * @param tmpl Already-compiled stylesheet to reuse
333      * @param fmt Already-allocated formatted-string scalar to reuse
334      * @checkstyle ParameterNumberCheck (5 lines)
335      */
336     private XSLDocument(final String src, final Sources srcs,
337         final Map<String, Object> map, final String base,
338         final Unchecked<Templates> tmpl, final Unchecked<String> fmt) {
339         this.xsl = src;
340         this.sources = srcs;
341         this.params = new HashMap<>(map);
342         this.sid = base;
343         this.templates = tmpl;
344         this.formatted = fmt;
345     }
346 
347     @Override
348     public XSL with(final Sources src) {
349         return new XSLDocument(this.xsl, src, this.params, this.sid);
350     }
351 
352     @Override
353     public XSL with(final String name, final Object value) {
354         return new XSLDocument(
355             this.xsl,
356             this.sources,
357             new MapOf<String, Object>(this.params, new MapEntry<>(name, value)),
358             this.sid,
359             this.templates,
360             this.formatted
361         );
362     }
363 
364     /**
365      * Make an instance of XSL stylesheet without I/O exceptions.
366      *
367      * <p>This factory method is useful when you need to create
368      * an instance of XSL stylesheet as a static final variable. In this
369      * case you can't catch an exception but this method can help, for example:
370      *
371      * <pre> class Foo {
372      *   private static final XSL STYLESHEET = XSLDocument.make(
373      *     Foo.class.getResourceAsStream("my-stylesheet.xsl")
374      *   );
375      * }</pre>
376      *
377      * @param stream Input stream
378      * @return XSL stylesheet
379      */
380     @SuppressWarnings("PMD.ProhibitPublicStaticMethods")
381     public static XSL make(final InputStream stream) {
382         return new XSLDocument(stream);
383     }
384 
385     /**
386      * Make an instance of XSL stylesheet without I/O exceptions.
387      * @param url URL with content
388      * @return XSL stylesheet
389      * @see #make(InputStream)
390      * @since 0.7.4
391      */
392     @SuppressWarnings("PMD.ProhibitPublicStaticMethods")
393     public static XSL make(final URL url) {
394         try {
395             return new XSLDocument(url, url.toString());
396         } catch (final IOException ex) {
397             throw new IllegalStateException(
398                 String.format(
399                     "Failed to read from URL '%s'",
400                     url
401                 ),
402                 ex
403             );
404         }
405     }
406 
407     @Override
408     public String toString() {
409         return this.formatted.value();
410     }
411 
412     @Override
413     public XML transform(final XML xml) {
414         final Document target = XSLDocument.DBUILDER.get().newDocument();
415         this.transformInto(xml, new DOMResult(target));
416         return new XMLDocument(target);
417     }
418 
419     @Override
420     public String applyTo(final XML xml) {
421         final ByteArrayOutputStream baos = new ByteArrayOutputStream();
422         this.transformInto(xml, new StreamResult(baos));
423         try {
424             return baos.toString(StandardCharsets.UTF_8.name());
425         } catch (final UnsupportedEncodingException ex) {
426             throw new IllegalArgumentException(
427                 "Failed to convert bytes into UTF-8 string",
428                 ex
429             );
430         }
431     }
432 
433     /**
434      * Transform XML into result.
435      *
436      * @param xml XML
437      * @param result Result
438      * @since 0.11
439      */
440     @SuppressWarnings("PMD.UnnecessaryLocalRule")
441     private void transformInto(final XML xml, final Result result) {
442         final Transformer trans = this.transformer();
443         final ConsoleErrorListener errors = new ConsoleErrorListener();
444         trans.setErrorListener(errors);
445         final long start = System.nanoTime();
446         try {
447             trans.transform(new DOMSource(xml.inner()), result);
448         } catch (final TransformerException ex) {
449             final StringBuilder summary = new StringBuilder(
450                 String.join("; ", errors.summary())
451             );
452             if (!summary.toString().equals(ex.getMessageAndLocation())) {
453                 summary.append("; ").append(ex.getMessageAndLocation());
454             }
455             throw new IllegalArgumentException(
456                 String.format(
457                     "Failed to transform by %s: %s",
458                     trans.getClass().getName(),
459                     summary
460                 ),
461                 ex
462             );
463         }
464         if (Logger.isTraceEnabled(this)) {
465             Logger.trace(
466                 this,
467                 "%s transformed XML in %[nano]s",
468                 trans.getClass().getName(),
469                 System.nanoTime() - start
470             );
471         }
472     }
473 
474     /**
475      * Make a transformer from the cached compiled stylesheet.
476      * @return The transformer
477      */
478     private Transformer transformer() {
479         final Templates templ = this.templates.value();
480         final Transformer trans;
481         try {
482             trans = templ.newTransformer();
483         } catch (final TransformerConfigurationException ex) {
484             throw new IllegalArgumentException(
485                 String.format(
486                     "Failed to create transformer by %s",
487                     templ.getClass().getName()
488                 ),
489                 ex
490             );
491         }
492         trans.setURIResolver(this.sources);
493         for (final Map.Entry<String, Object> ent : this.params.entrySet()) {
494             trans.setParameter(ent.getKey(), ent.getValue());
495         }
496         return trans;
497     }
498 
499     /**
500      * Lazy pretty-printed string form of an XSL document.
501      * @param xsl XSL document body
502      * @return Cached formatted string
503      */
504     private static Unchecked<String> format(final String xsl) {
505         return new Unchecked<>(
506             new Synced<>(new Sticky<>(() -> new XMLDocument(xsl).toString()))
507         );
508     }
509 
510     /**
511      * Lazy-load and cache the compiled {@link Templates} object.
512      * @param sources URI resolver for xsl:import/xsl:include
513      * @param xsl XSL document body
514      * @param sid System ID (base)
515      * @return Cached compiled stylesheet
516      */
517     private static Unchecked<Templates> load(
518         final Sources sources,
519         final String xsl,
520         final String sid
521     ) {
522         return new Unchecked<>(
523             new Synced<>(new Sticky<>(() -> XSLDocument.doLoad(sources, xsl, sid)))
524         );
525     }
526 
527     /**
528      * Compile the stylesheet to a reusable {@link Templates} object.
529      *
530      * <p>We create {@link TransformerFactory} here during compilation
531      * because {@link javax.xml.transform.URIResolver} must be set into
532      * it before making an instance of a transformer. Otherwise, it won't
533      * understand "xsl:import" statements.
534      *
535      * @param sources URI resolver for xsl:import/xsl:include
536      * @param xsl XSL document body
537      * @param sid System ID (base)
538      * @return Compiled stylesheet
539      * @link <a href="https://stackoverflow.com/questions/4695489">Relevant SO question</a>
540      */
541     private static Templates doLoad(
542         final Sources sources,
543         final String xsl,
544         final String sid
545     ) {
546         final TransformerFactory factory = TransformerFactory.newInstance();
547         final ConsoleErrorListener errors = new ConsoleErrorListener();
548         factory.setErrorListener(errors);
549         factory.setURIResolver(sources);
550         final Templates tmpl;
551         try {
552             tmpl = factory.newTemplates(
553                 new StreamSource(new StringReader(xsl), sid)
554             );
555         } catch (final TransformerConfigurationException ex) {
556             throw new IllegalArgumentException(
557                 String.format(
558                     "Failed to create transformer by %s",
559                     factory.getClass().getName()
560                 ),
561                 ex
562             );
563         }
564         if (!errors.summary().isEmpty()) {
565             throw new IllegalArgumentException(
566                 String.format(
567                     "Failed to compile stylesheet by %s: %s",
568                     factory.getClass().getName(),
569                     errors.summary()
570                 )
571             );
572         }
573         return tmpl;
574     }
575 }