001/*-------------------------------------------------------------------------+
002|                                                                          |
003| Copyright 2005-2011 The ConQAT Project                                   |
004|                                                                          |
005| Licensed under the Apache License, Version 2.0 (the "License");          |
006| you may not use this file except in compliance with the License.         |
007| You may obtain a copy of the License at                                  |
008|                                                                          |
009|    http://www.apache.org/licenses/LICENSE-2.0                            |
010|                                                                          |
011| Unless required by applicable law or agreed to in writing, software      |
012| distributed under the License is distributed on an "AS IS" BASIS,        |
013| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
014| See the License for the specific language governing permissions and      |
015| limitations under the License.                                           |
016+-------------------------------------------------------------------------*/
017package org.conqat.lib.commons.xml;
018
019import static org.conqat.lib.commons.string.StringUtils.LINE_SEPARATOR;
020import static org.conqat.lib.commons.string.StringUtils.SPACE;
021
022import java.io.Closeable;
023import java.io.OutputStream;
024import java.io.OutputStreamWriter;
025import java.io.PrintWriter;
026import java.io.UnsupportedEncodingException;
027import java.util.EmptyStackException;
028import java.util.HashSet;
029import java.util.Stack;
030
031import org.conqat.lib.commons.filesystem.FileSystemUtils;
032import org.conqat.lib.commons.string.StringUtils;
033
034/**
035 * Utility class for creating XML documents. Please consult test case
036 * XMLWriterTest to see how this class is intended to be used.
037 */
038public class XMLWriter<E extends Enum<E>, A extends Enum<A>> implements Closeable {
039
040        /** This enunmeration describes the states of the writer. */
041        private enum EState {
042
043                /** Indicates that we are at the beginning of the document. */
044                DOCUMENT_START,
045
046                /** Started a tag but did not close it yet. */
047                INSIDE_TAG,
048
049                /** Inside a text element. */
050                INSIDE_TEXT,
051
052                /**
053                 * Indicates that we are between to tags but not within a text element.
054                 */
055                OUTSIDE_TAG
056        }
057
058        /** XML comment end symbol. */
059        private final static String COMMENT_END = " -->";
060
061        /** XML comment start symbol. */
062        private final static String COMMENT_START = "<!-- ";
063
064        /** Right angle bracket */
065        private final static String GT = ">";
066
067        /** Left angle bracket */
068        private final static String LT = "<";
069
070        /** Resolver used by the writer. */
071        protected final IXMLResolver<E, A> xmlResolver;
072
073        /**
074         * This set maintains the attributes added to an element to detect duplicate
075         * attributes.
076         * <p>
077         * We can not use {@link java.util.EnumSet} here as we would need a reference to
078         * the defining class.
079         */
080        private final HashSet<A> currentAttributes = new HashSet<A>();
081
082        /** The current nesting depth is used to calculate the ident. */
083        private int currentNestingDepth = -1;
084
085        /**
086         * This stack maintains the elements in order of creation. It is used to check
087         * if elements are closed in the correct order.
088         */
089        private final Stack<E> elementStack = new Stack<E>();
090
091        /** The current state of the writer. */
092        private EState state = EState.DOCUMENT_START;
093
094        /** The writer to write to. */
095        private final PrintWriter writer;
096
097        /** This flag indicates if line breaks should be generated or not. */
098        private boolean suppressLineBreaks = false;
099
100        /**
101         * Create a new writer.
102         * 
103         * @param stream
104         *            the stream to write to.
105         * @param xmlResolver
106         *            resolvers used by this writer
107         */
108        public XMLWriter(OutputStream stream, IXMLResolver<E, A> xmlResolver) {
109                try {
110                        this.writer = new PrintWriter(new OutputStreamWriter(stream, FileSystemUtils.UTF8_ENCODING));
111                } catch (UnsupportedEncodingException e) {
112                        throw new AssertionError("UTF-8 should always be supported!");
113                }
114                this.xmlResolver = xmlResolver;
115        }
116
117        /**
118         * Create a new writer.
119         * 
120         * @param writer
121         *            the writer to write to.
122         * @param xmlResolver
123         *            resolvers used by this writer
124         */
125        public XMLWriter(PrintWriter writer, IXMLResolver<E, A> xmlResolver) {
126                this.writer = writer;
127                this.xmlResolver = xmlResolver;
128        }
129
130        /**
131         * Toogle line break behavior. If set to <code>true</code> the writer does not
132         * write line breaks. If set to <code>false</code> (default) line breaks are
133         * written.
134         * <p>
135         * This can, for example, be used for HTML where line breaks sometimes change
136         * the layout.
137         */
138        public void setSuppressLineBreaks(boolean supressLineBreaks) {
139                this.suppressLineBreaks = supressLineBreaks;
140        }
141
142        /**
143         * Add an XML header.
144         * 
145         * @param version
146         *            version string
147         * @param encoding
148         *            encoding definition
149         */
150        public void addHeader(String version, String encoding) {
151                if (state != EState.DOCUMENT_START) {
152                        throw new XMLWriterException("Can be called at the beginning of a document only.",
153                                        EXMLWriterExceptionType.HEADER_WITHIN_DOCUMENT);
154                }
155                print(LT);
156                print("?xml version=\"");
157                print(version);
158                print("\" encoding=\"");
159                print(encoding);
160                print("\"?");
161                print(GT);
162
163                state = EState.OUTSIDE_TAG;
164        }
165
166        /**
167         * Add public document type definiton
168         * 
169         * @param rootElement
170         *            root element
171         * @param publicId
172         *            public id
173         * @param systemId
174         *            sytem id
175         */
176        public void addPublicDocTypeDefintion(E rootElement, String publicId, String systemId) {
177                print(LT);
178                print("!DOCTYPE ");
179                print(xmlResolver.resolveElementName(rootElement));
180                print(" PUBLIC \"");
181                print(publicId);
182                print("\" \"");
183                print(systemId);
184                print("\"");
185                print(GT);
186
187                state = EState.OUTSIDE_TAG;
188        }
189
190        /**
191         * Add the HTML5 doctype, which is different from normal XML/HTML doctypes in
192         * that it is much shorter and does not contain any public or system IDs.
193         */
194        public void addHTML5Doctype() {
195                print(LT);
196                print("!DOCTYPE html");
197                print(GT);
198
199                state = EState.OUTSIDE_TAG;
200        }
201
202        /**
203         * Start a new element
204         * 
205         * @param element
206         *            the element to start.
207         */
208        public void openElement(E element) {
209                if (state == EState.INSIDE_TAG) {
210                        println(GT);
211                } else if (state == EState.OUTSIDE_TAG) {
212                        println();
213                }
214
215                currentNestingDepth++;
216                if (state != EState.INSIDE_TEXT) {
217                        printIndent();
218                }
219                print(LT);
220                print(xmlResolver.resolveElementName(element));
221
222                state = EState.INSIDE_TAG;
223                elementStack.push(element);
224                currentAttributes.clear();
225        }
226
227        /**
228         * Add a attribute. This only works if a element was started but no other
229         * elements were added yet.
230         * 
231         * @param attribute
232         *            the attribute to create
233         * @param value
234         *            its value
235         * @throws XMLWriterException
236         *             if there's no element to add attributes to (
237         *             {@link EXMLWriterExceptionType#ATTRIBUTE_OUTSIDE_ELEMENT}) or if
238         *             an attribute is added twice (
239         *             {@link EXMLWriterExceptionType#DUPLICATE_ATTRIBUTE}).
240         */
241        public void addAttribute(A attribute, Object value) {
242                if (currentAttributes.contains(attribute)) {
243                        throw new XMLWriterException("Duplicate attribute.", EXMLWriterExceptionType.DUPLICATE_ATTRIBUTE);
244                }
245
246                addExternalAttribute(xmlResolver.resolveAttributeName(attribute), value);
247                currentAttributes.add(attribute);
248        }
249
250        /**
251         * Adds an external attribute, i.e. an attribute that was not defined in the
252         * attribute enumeration.
253         */
254        public void addExternalAttribute(String attributeName, Object value) {
255                if (state != EState.INSIDE_TAG) {
256                        throw new XMLWriterException("Must be called for an open element.",
257                                        EXMLWriterExceptionType.ATTRIBUTE_OUTSIDE_ELEMENT);
258                }
259
260                print(SPACE);
261                print(attributeName);
262                print("=");
263                print("\"");
264                print(escape(value.toString()));
265                print("\"");
266        }
267
268        /**
269         * Convenience method for adding an element together with (some of) its
270         * attributes.
271         * 
272         * @param element
273         *            The element to be opened (using {@link #openElement(Enum)}).
274         * @param attributes
275         *            the attributes to be added. The number of arguments must be even,
276         *            where the first, third, etc. argument is an attribute enum.
277         */
278        public void openElement(E element, Object... attributes) {
279                if (attributes.length % 2 != 0) {
280                        throw new XMLWriterException("Expected an even number of arguments!",
281                                        EXMLWriterExceptionType.ODD_NUMBER_OF_ARGUMENTS);
282                }
283                for (int i = 0; i < attributes.length; i += 2) {
284                        if (!xmlResolver.getAttributeClass().isAssignableFrom(attributes[i].getClass())) {
285                                throw new XMLWriterException(
286                                                "Attribute name (index " + i + ") must be of type " + xmlResolver.getAttributeClass().getName(),
287                                                EXMLWriterExceptionType.ILLEGAL_ATTRIBUTE_TYPE);
288                        }
289                }
290                openElement(element);
291                for (int i = 0; i < attributes.length; i += 2) {
292                        // this is ok as we checked it above
293                        @SuppressWarnings("unchecked")
294                        A a = (A) attributes[i];
295                        addAttribute(a, attributes[i + 1]);
296                }
297        }
298
299        /**
300         * Convenience method for adding an element together with (some of) its
301         * attributes. This is the same as {@link #openElement(Enum, Object[])}, but
302         * also closes the element.
303         */
304        public void addClosedElement(E element, Object... attributes) {
305                openElement(element, attributes);
306                closeElement(element);
307        }
308
309        /**
310         * Convenience method for adding an element together with (some of) its
311         * attributes and text inbetween. This is the same as
312         * {@link #openElement(Enum, Object[])}, but then adds the provided text and
313         * closes the element.
314         */
315        public void addClosedTextElement(E element, String text, Object... attributes) {
316                openElement(element, attributes);
317                addText(text);
318                closeElement(element);
319        }
320
321        /**
322         * Close an element.
323         * 
324         * @param element
325         *            the element to close.
326         * @throws XMLWriterException
327         *             on attempt to close the wrong element (
328         *             {@link EXMLWriterExceptionType#UNCLOSED_ELEMENT}).
329         */
330        public void closeElement(E element) {
331                if (element != elementStack.peek()) {
332                        throw new XMLWriterException("Must close element " + elementStack.peek() + " first.",
333                                        EXMLWriterExceptionType.UNCLOSED_ELEMENT);
334                }
335
336                if (state == EState.INSIDE_TAG) {
337                        // if inside a tag, just close the tag and done
338                        print(" /");
339                        print(GT);
340
341                } else {
342                        // we're not inside a tag
343
344                        if (state != EState.INSIDE_TEXT) {
345                                // if not inside a text element, create new line and indent
346                                println();
347                                printIndent();
348                        }
349
350                        // create closing tag
351                        print(LT);
352                        print("/");
353                        print(xmlResolver.resolveElementName(element));
354                        print(GT);
355                }
356
357                // we're done with this element
358                elementStack.pop();
359                currentNestingDepth--;
360                state = EState.OUTSIDE_TAG;
361        }
362
363        /**
364         * Add a text element to an element.
365         * 
366         * @param text
367         *            the text to add.
368         */
369        public void addText(String text) {
370                if (state == EState.INSIDE_TAG) {
371                        print(GT);
372                }
373                print(escape(text));
374
375                state = EState.INSIDE_TEXT;
376        }
377
378        /**
379         * Add CDATA section. Added text is not escaped.
380         * 
381         * @throws XMLWriterException
382         *             If the added text contains the CDATA closing tag
383         *             <code>]]></code>. This is not automatically escaped as some
384         *             parsers do not automatically unescape it when reading.
385         */
386        public void addCDataSection(String cdata) {
387                if (state == EState.INSIDE_TAG) {
388                        print(GT);
389                }
390
391                if (cdata.contains("]]>")) {
392                        throw new XMLWriterException("CDATA contains ']]>'",
393                                        EXMLWriterExceptionType.CDATA_CONTAINS_CDATA_CLOSING_TAG);
394                }
395
396                print("<![CDATA[");
397                print(cdata);
398                print("]]>");
399                state = EState.INSIDE_TEXT;
400        }
401
402        /**
403         * Add an XML comment.
404         * 
405         * @param text
406         *            comment text.
407         */
408        public void addComment(String text) {
409                ensureOutsideTag();
410
411                currentNestingDepth++;
412                printIndent();
413                print(COMMENT_START);
414                // XML comments must not include --
415                print(escape(text.replace("--", "__")));
416                print(COMMENT_END);
417                currentNestingDepth--;
418
419                state = EState.OUTSIDE_TAG;
420        }
421
422        /** Add new line. */
423        public void addNewLine() {
424                ensureOutsideTag();
425        }
426
427        /**
428         * Close the writer.
429         * 
430         * @throws XMLWriterException
431         *             if there is a remaining open element.
432         */
433        @Override
434        public void close() {
435                if (!elementStack.isEmpty()) {
436                        throw new XMLWriterException("Need to close element <" + xmlResolver.resolveElementName(elementStack.peek())
437                                        + "> before closing writer.", EXMLWriterExceptionType.UNCLOSED_ELEMENT);
438                }
439                writer.close();
440        }
441
442        /** Flushes the underlying writer. */
443        public void flush() {
444                writer.flush();
445        }
446
447        /**
448         * Adds the given text unprocessed to the writer. This is useful for adding
449         * chunks of generated XML to avoid having the brackets escaped.
450         */
451        protected void addRawString(String text) {
452                if (state == EState.INSIDE_TAG) {
453                        print(GT);
454                }
455                print(text);
456                state = EState.INSIDE_TEXT;
457        }
458
459        /** Get writer this writer writes to. */
460        protected PrintWriter getWriter() {
461                return writer;
462        }
463
464        /**
465         * Returns the element we are currently in.
466         * 
467         * @throws EmptyStackException
468         *             if there is no unclosed element.
469         */
470        protected E getCurrentElement() {
471                return elementStack.peek();
472        }
473
474        /** Make sure the current tag is closed. */
475        private void ensureOutsideTag() {
476                if (state == EState.INSIDE_TAG) {
477                        println(GT);
478                        state = EState.OUTSIDE_TAG;
479                } else if (state == EState.OUTSIDE_TAG) {
480                        println();
481                }
482        }
483
484        /**
485         * Escape text for XML. Creates empty string for <code>null</code> value.
486         */
487        public static String escape(String text) {
488                if (text == null) {
489                        return StringUtils.EMPTY_STRING;
490                }
491
492                text = text.replaceAll("&", "&amp;");
493                text = text.replaceAll(LT, "&lt;");
494                text = text.replaceAll(GT, "&gt;");
495                text = text.replaceAll("\"", "&quot;");
496
497                // normalize line breaks
498                text = StringUtils.replaceLineBreaks(text, LINE_SEPARATOR);
499                return text;
500        }
501
502        /** Write to writer. */
503        private void print(String message) {
504                writer.print(message);
505        }
506
507        /** Write indent to writer. */
508        private void printIndent() {
509                if (!suppressLineBreaks) {
510                        writer.print(StringUtils.fillString(currentNestingDepth * 2, StringUtils.SPACE_CHAR));
511                }
512        }
513
514        /** Write to writer. */
515        private void println() {
516                if (!suppressLineBreaks) {
517                        print(LINE_SEPARATOR);
518                }
519        }
520
521        /** Write to writer. */
522        private void println(String text) {
523                print(text);
524                println();
525        }
526}