001/**
002 * jshred - Shred's Toolbox
003 *
004 * Copyright (C) 2009 Richard "Shred" Körber
005 *   http://jshred.shredzone.org
006 *
007 * This program is free software: you can redistribute it and/or modify
008 * it under the terms of the GNU General Public License / GNU Lesser
009 * General Public License as published by the Free Software Foundation,
010 * either version 3 of the License, or (at your option) any later version.
011 *
012 * Licensed under the Apache License, Version 2.0 (the "License");
013 * you may not use this file except in compliance with the License.
014 *
015 * This program is distributed in the hope that it will be useful,
016 * but WITHOUT ANY WARRANTY; without even the implied warranty of
017 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
018 *
019 */
020package net.shredzone.jshred.io;
021
022import java.io.BufferedWriter;
023import java.io.IOException;
024import java.io.OutputStream;
025import java.io.OutputStreamWriter;
026import java.io.UnsupportedEncodingException;
027import java.io.Writer;
028import java.util.Map;
029import java.util.Stack;
030
031import org.xml.sax.Attributes;
032
033/**
034 * A very simple writer for cleanly formatted XML output.
035 * <p>
036 * This writer will do for creating simple XML files, but please note that it is not
037 * perfect at all. It does not support namespace, DTDs and it is unable to read DOM
038 * structures as in the {@code org.w3c.dom} package. And last but not least it does
039 * not validate your XML against a DTD.
040 * <p>
041 * What it <em>does</em> is indention of the text, proper escaping of XML special chars
042 * and correct charset encoding.
043 *
044 * @author Richard "Shred" Körber
045 */
046public class XMLWriter extends BufferedWriter {
047    private String indent = "  ";
048    private String charset = "UTF-8";
049    private int level = 0;
050    private Stack<String> sElements = new Stack<>();
051    private StringBuilder bTag = new StringBuilder();
052    private String store = null;
053
054    /**
055     * Creates a new XMLWriter basing on a {@link Writer}.
056     *
057     * @param out
058     *            {@link Writer} to send the XML data to
059     */
060    public XMLWriter(Writer out) {
061        super(out);
062        if (out instanceof OutputStreamWriter) {
063            charset = ((OutputStreamWriter) out).getEncoding();
064        }
065    }
066
067    /**
068     * Creates a new XMLWriter basing on an {@link OutputStream}. The {@link OutputStream}
069     * will receive UTF-8 encoded data.
070     *
071     * @param out
072     *            {@link OutputStream} to send the XML data to
073     * @throws UnsupportedEncodingException
074     *             if this VM does not support UTF-8, which should never happen...
075     */
076    public XMLWriter(OutputStream out) throws UnsupportedEncodingException {
077        this(new OutputStreamWriter(out, "UTF-8"));
078    }
079
080    /**
081     * Sets the indention string. This string is used to indent lines according to their
082     * nesting. Defaults to two spaces, but you can also set several spaces, tabs or an
083     * empty string.
084     * <p>
085     * If you want to change the indention string, you must do so before invoking
086     * {@link #startDocument()}.
087     *
088     * @param indent
089     *            New Indention String
090     */
091    public void setIndent(String indent) {
092        this.indent = indent;
093    }
094
095    /**
096     * Sets the encoding used by this writer. The XMLWriter tries to find out the proper
097     * encoding itself. If an @{link OutputStream} is used, encoding will always be UTF-8.
098     * If an @{link OutputStreamWriter} was passed in, its current encoding will be used.
099     * In any other case UTF-8 is assumed for encoding, which can be changed by using this
100     * method.
101     * <p>
102     * If you want to change the encoding string, you must do so before invoking
103     * {@link #startDocument()}.
104     *
105     * @param encoding
106     *            New encoding
107     */
108    public void setEncoding(String encoding) {
109        this.charset = encoding;
110    }
111
112    /**
113     * Indents by one level
114     */
115    protected void writeIndent() throws IOException {
116        for (int cnt = 0; cnt < level; cnt++) {
117            write(indent);
118        }
119    }
120
121    /**
122     * Starts an XML document. The XML header will be written.
123     */
124    public void startDocument() throws IOException {
125        if (charset.equalsIgnoreCase("UTF8")) charset = "UTF-8";
126        write("<?xml version=\"1.0\" encoding=\"" + charset + "\"?>");
127        newLine();
128        newLine();
129    }
130
131    /**
132     * Finishes an XML document.
133     */
134    public void endDocument() throws IOException {
135        flushTag();
136        if (!sElements.isEmpty()) {
137            throw new IOException("Still open elements");
138        }
139    }
140
141    /**
142     * Starts a new XML element. There are no attributes added to this element.
143     *
144     * @param element
145     *            Element name
146     */
147    public void startElement(String element) throws IOException {
148        startElement(element, (Attributes) null);
149    }
150
151    /**
152     * Starts a new XML element with attributes. This method will take proper care for
153     * excaping all chars within the attribute values.
154     *
155     * @param element
156     *            Element name
157     * @param attr
158     *            Attribute name/value pairs
159     * @since R6
160     */
161    public void startElement(String element, String... attr) throws IOException {
162        if (attr.length % 2 == 1)
163            throw new IllegalArgumentException("Number of attr/value pairs must be even!");
164
165        org.xml.sax.helpers.AttributesImpl xmla = new org.xml.sax.helpers.AttributesImpl();
166        for (int ix = 0; ix < attr.length; ix += 2) {
167            xmla.addAttribute("", "", attr[ix], "CDATA", attr[ix + 1]);
168        }
169        startElement(element, xmla);
170    }
171
172    /**
173     * Starts a new XML element with attributes. This method will take proper care for
174     * excaping all chars within the attribute values.
175     * <p>
176     * Note that since R15, the map is required to have {@link String} keys and values.
177     *
178     * @param element
179     *            Element name
180     * @param attrs
181     *            Map with attributes and corresponding values
182     * @since R6
183     */
184    public void startElement(String element, Map<String, String> attrs)
185    throws IOException {
186        org.xml.sax.helpers.AttributesImpl xmla = new org.xml.sax.helpers.AttributesImpl();
187        for (String key : attrs.keySet()) {
188            xmla.addAttribute("", "", key, "CDATA", attrs.get(key));
189        }
190        startElement(element, xmla);
191    }
192
193    /**
194     * Starts a new XML element with attributes. This method will take proper care for
195     * excaping all chars within the attribute values.
196     *
197     * @param element
198     *            Element name
199     * @param attr
200     *            Attributes
201     */
202    public void startElement(String element, Attributes attr) throws IOException {
203        flushTag();
204        sElements.push(element);
205        writeIndent();
206        bTag.append(element);
207
208        // --- Append attributes ---
209        if (attr != null && attr.getLength() > 0) {
210            int cnt = attr.getLength();
211            for (int ix = 0; ix < cnt; ix++) {
212                bTag.append(' ');
213                bTag.append(attr.getQName(ix));
214                bTag.append("=\"");
215                bTag.append(escape(attr.getValue(ix)));
216                bTag.append('"');
217            }
218        }
219        level++;
220    }
221
222    /**
223     * Closes the most recent XML element. The XMLWriter recognizes empty elements and will
224     * send a shortcut to the output.
225     */
226    public void endElement() throws IOException {
227        String element = sElements.pop();
228        if (element == null) {
229            throw new IOException("Too many elements closed");
230        }
231
232        if (store != null && bTag.length() > 0) {
233            level--;
234            write('<');
235            write(bTag.toString());
236            write('>');
237            write(escape(store));
238            write("</");
239            write(element);
240            write('>');
241            store = null;
242            bTag = new StringBuilder();
243        } else {
244            if (store != null) {
245                writeIndent();
246                write(escape(store));
247                newLine();
248                store = null;
249            }
250            level--;
251            if (bTag.length() > 0) {
252                write('<');
253                write(bTag.toString());
254                write("/>");
255                bTag = new StringBuilder();
256            } else {
257                writeIndent();
258                write("</" + element + '>');
259            }
260        }
261        newLine();
262    }
263
264    /**
265     * Writes the content of an XML container. Leading and trailing spaces will be trimmed.
266     * Empty contents will be ignored. You can invoke this method several times for a
267     * container. Each content will then be written into a separate line. Special chars
268     * will automatically be escaped.
269     *
270     * @param content
271     *            Content of the current XML container
272     */
273    public void writeContent(String content) throws IOException {
274        // --- Ignore empty container ---
275        content = content.trim();
276        if (content.equals("")) return;
277
278        if (store == null) {
279            // --- Temporary store it ---
280            store = content;
281        } else {
282            // --- Write it ---
283            flushTag();
284            writeIndent();
285            write(escape(content));
286            newLine();
287        }
288    }
289
290    /**
291     * Writes a comment. Content will be escaped properly.
292     *
293     * @param comment
294     *            The comment's content.
295     */
296    public void writeComment(String comment) throws IOException {
297        // --- Ignore comment ---
298        comment = comment.trim();
299        if (comment.equals("")) return;
300
301        // --- Write it ---
302        flushTag();
303        writeIndent();
304        write("<!-- " + escape(comment) + " -->");
305        newLine();
306    }
307
308    /**
309     * Escapes a String so it can be used in XML context. All &amp;, &lt;, &gt; and &quot;
310     * will be converted into their respective entity.
311     *
312     * @param text
313     *            Text to be escaped
314     * @return Escapted text
315     */
316    public static String escape(String text) {
317        text = text.replaceAll("\\&", "&amp;");
318        text = text.replaceAll("\\<", "&lt;");
319        text = text.replaceAll("\\>", "&gt;");
320        text = text.replaceAll("\\\"", "&quot;");
321        return text;
322    }
323
324    /**
325     * Flushes the tag buffer, which is used to write empty containers in their short
326     * form.
327     */
328    protected void flushTag() throws IOException {
329        if (bTag.length() > 0) {
330            write("<");
331            write(bTag.toString());
332            write(">");
333            newLine();
334            bTag = new StringBuilder();
335        }
336        if (store != null) {
337            writeIndent();
338            write(escape(store));
339            newLine();
340            store = null;
341        }
342    }
343}