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("&", "&"); 493 text = text.replaceAll(LT, "<"); 494 text = text.replaceAll(GT, ">"); 495 text = text.replaceAll("\"", """); 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}