All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.helger.xml.serialize.write.XMLEmitter Maven / Gradle / Ivy

There is a newer version: 11.1.8
Show newest version
/*
 * Copyright (C) 2014-2023 Philip Helger (www.helger.com)
 * philip[at]helger[dot]com
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.helger.xml.serialize.write;

import java.io.Flushable;
import java.io.IOException;
import java.io.Writer;
import java.util.Map;

import javax.annotation.Nonnegative;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.WillNotClose;
import javax.annotation.concurrent.NotThreadSafe;
import javax.xml.XMLConstants;
import javax.xml.namespace.QName;

import com.helger.commons.ValueEnforcer;
import com.helger.commons.collection.impl.CommonsTreeMap;
import com.helger.commons.collection.impl.ICommonsList;
import com.helger.commons.collection.impl.ICommonsSortedMap;
import com.helger.commons.state.ETriState;
import com.helger.commons.string.StringHelper;
import com.helger.commons.string.ToStringGenerator;
import com.helger.xml.CXML;
import com.helger.xml.EXMLVersion;
import com.helger.xml.microdom.IMicroDocumentType;

/**
 * Converts XML constructs into a string representation.
 *
 * @author Philip Helger
 */
@NotThreadSafe
public class XMLEmitter implements AutoCloseable, Flushable
{
  /** By default an exception is thrown for nested comments */
  public static final boolean DEFAULT_THROW_EXCEPTION_ON_NESTED_COMMENTS = true;
  public static final String CDATA_START = "";
  public static final String COMMENT_START = "";
  public static final char ER_START = '&';
  public static final char ER_END = ';';
  public static final String PI_START = "";

  private static boolean s_bThrowExceptionOnNestedComments = DEFAULT_THROW_EXCEPTION_ON_NESTED_COMMENTS;

  private final Writer m_aWriter;
  private final IXMLWriterSettings m_aXMLWriterSettings;
  // Status vars
  private EXMLSerializeVersion m_eXMLVersion;
  private final char m_cAttrValueBoundary;
  private final EXMLCharMode m_eAttrValueCharMode;
  private final boolean m_bOrderAttributesAndNamespaces;

  /**
   * Define whether nested XML comments throw an exception or not.
   *
   * @param bThrowExceptionOnNestedComments
   *        true to throw an exception, false to
   *        ignore nested comments.
   */
  public static void setThrowExceptionOnNestedComments (final boolean bThrowExceptionOnNestedComments)
  {
    s_bThrowExceptionOnNestedComments = bThrowExceptionOnNestedComments;
  }

  /**
   * @return true if nested XML comments will throw an error.
   *         Default is {@value #DEFAULT_THROW_EXCEPTION_ON_NESTED_COMMENTS}.
   */
  public static boolean isThrowExceptionOnNestedComments ()
  {
    return s_bThrowExceptionOnNestedComments;
  }

  public XMLEmitter (@Nonnull @WillNotClose final Writer aWriter, @Nonnull final IXMLWriterSettings aSettings)
  {
    m_aWriter = ValueEnforcer.notNull (aWriter, "Writer");
    m_aXMLWriterSettings = ValueEnforcer.notNull (aSettings, "Settings");
    m_eXMLVersion = aSettings.getSerializeVersion ();
    m_cAttrValueBoundary = aSettings.isUseDoubleQuotesForAttributes () ? '"' : '\'';
    m_eAttrValueCharMode = aSettings.isUseDoubleQuotesForAttributes () ? EXMLCharMode.ATTRIBUTE_VALUE_DOUBLE_QUOTES
                                                                       : EXMLCharMode.ATTRIBUTE_VALUE_SINGLE_QUOTES;
    m_bOrderAttributesAndNamespaces = aSettings.isOrderAttributesAndNamespaces ();
  }

  /**
   * @return The XML writer settings as provided in the constructor. Never
   *         null.
   */
  @Nonnull
  public IXMLWriterSettings getXMLWriterSettings ()
  {
    return m_aXMLWriterSettings;
  }

  @Nonnull
  private XMLEmitter _append (@Nonnull final String sValue)
  {
    try
    {
      m_aWriter.write (sValue);
      return this;
    }
    catch (final IOException ex)
    {
      throw new IllegalStateException ("Failed to append string '" + sValue + "'", ex);
    }
  }

  @Nonnull
  private XMLEmitter _append (final char cValue)
  {
    try
    {
      m_aWriter.write (cValue);
      return this;
    }
    catch (final IOException ex)
    {
      throw new IllegalStateException ("Failed to append character '" + cValue + "'", ex);
    }
  }

  @Nonnull
  private XMLEmitter _append (final char [] aValue, @Nonnegative final int nOfs, @Nonnegative final int nLen)
  {
    try
    {
      m_aWriter.write (aValue, nOfs, nLen);
      return this;
    }
    catch (final IOException ex)
    {
      throw new IllegalStateException ("Failed to append character '" + new String (aValue, nOfs, nLen) + "'", ex);
    }
  }

  @Nonnull
  private XMLEmitter _appendMasked (@Nonnull final EXMLCharMode eXMLCharMode, @Nullable final String sValue)
  {
    try
    {
      XMLMaskHelper.maskXMLTextTo (m_eXMLVersion, eXMLCharMode, m_aXMLWriterSettings.getIncorrectCharacterHandling (), sValue, m_aWriter);
      return this;
    }
    catch (final IOException ex)
    {
      throw new IllegalStateException ("Failed to append masked string '" + sValue + "'", ex);
    }
  }

  @Nonnull
  private XMLEmitter _appendMasked (@Nonnull final EXMLCharMode eXMLCharMode,
                                    @Nonnull final char [] aText,
                                    @Nonnegative final int nOfs,
                                    @Nonnegative final int nLen)
  {
    try
    {
      XMLMaskHelper.maskXMLTextTo (m_eXMLVersion,
                                   eXMLCharMode,
                                   m_aXMLWriterSettings.getIncorrectCharacterHandling (),
                                   aText,
                                   nOfs,
                                   nLen,
                                   m_aWriter);
      return this;
    }
    catch (final IOException ex)
    {
      throw new IllegalStateException ("Failed to append masked char[] '" + new String (aText, nOfs, nLen) + "'", ex);
    }
  }

  @Nonnull
  private XMLEmitter _appendAttrValue (@Nullable final String sValue)
  {
    return _append (m_cAttrValueBoundary)._appendMasked (m_eAttrValueCharMode, sValue)._append (m_cAttrValueBoundary);
  }

  @Nonnull
  public XMLEmitter newLine ()
  {
    if (m_aXMLWriterSettings.getIndent ().isAlign ())
      _append (m_aXMLWriterSettings.getNewLineString ());
    return this;
  }

  /**
   * At the very beginning of the document (XML declaration).
   *
   * @param eXMLVersion
   *        The XML version to use. If null is passed,
   *        {@link EXMLVersion#XML_10} will be used.
   * @param sEncoding
   *        The encoding to be used for this document. It may be
   *        null but it is strongly recommended to write a correct
   *        charset.
   * @param eStandalone
   *        if true this is a standalone XML document without a
   *        connection to an existing DTD or XML schema
   * @param bWithNewLine
   *        true to add a newline, false if not
   * @since 9.2.1
   */
  public void onXMLDeclaration (@Nullable final EXMLVersion eXMLVersion,
                                @Nullable final String sEncoding,
                                @Nonnull final ETriState eStandalone,
                                final boolean bWithNewLine)
  {
    if (eXMLVersion != null)
    {
      // Maybe switch from 1.0 to 1.1 or vice versa at the very beginning of the
      // document
      m_eXMLVersion = EXMLSerializeVersion.getFromXMLVersionOrThrow (eXMLVersion);
    }

    _append (PI_START)._append ("xml");

    // May be null for HTML
    final String sVersionString = m_eXMLVersion.getXMLVersionString ();
    if (StringHelper.hasText (sVersionString))
      _append (" version=")._appendAttrValue (sVersionString);

    if (StringHelper.hasText (sEncoding))
      _append (" encoding=")._appendAttrValue (sEncoding);

    if (eStandalone.isDefined ())
      _append (" standalone=")._appendAttrValue (eStandalone.isTrue () ? "yes" : "no");

    _append (PI_END);
    if (bWithNewLine)
      newLine ();
  }

  /**
   * Write a DTD section. This string represents the entire doctypedecl
   * production from the XML 1.0 specification.
   *
   * @param sDTD
   *        the DTD to be written. May not be null.
   */
  public void onDTD (@Nonnull final String sDTD)
  {
    _append (sDTD);
    newLine ();
  }

  /**
   * Get the XML representation of a document type.
   *
   * @param eXMLVersion
   *        The XML version to use. May not be null.
   * @param eIncorrectCharHandling
   *        The incorrect character handling. May not be null.
   * @param aDocType
   *        The structure document type. May not be null.
   * @return The string DOCTYPE representation.
   */
  @Nonnull
  public static String getDocTypeHTMLRepresentation (@Nonnull final EXMLSerializeVersion eXMLVersion,
                                                     @Nonnull final EXMLIncorrectCharacterHandling eIncorrectCharHandling,
                                                     @Nonnull final IMicroDocumentType aDocType)
  {
    return getDocTypeXMLRepresentation (eXMLVersion,
                                        eIncorrectCharHandling,
                                        aDocType.getQualifiedName (),
                                        aDocType.getPublicID (),
                                        aDocType.getSystemID ());
  }

  /**
   * Get the XML representation of a document type.
   *
   * @param eXMLVersion
   *        The XML version to use. May not be null.
   * @param eIncorrectCharHandling
   *        The incorrect character handling. May not be null.
   * @param sQualifiedName
   *        The qualified element name. May not be null.
   * @param sPublicID
   *        The optional public ID. May be null. If the public ID
   *        is not null the system ID must also be set!
   * @param sSystemID
   *        The optional system ID. May be null.
   * @return The string DOCTYPE representation.
   */
  @Nonnull
  public static String getDocTypeXMLRepresentation (@Nonnull final EXMLSerializeVersion eXMLVersion,
                                                    @Nonnull final EXMLIncorrectCharacterHandling eIncorrectCharHandling,
                                                    @Nonnull final String sQualifiedName,
                                                    @Nullable final String sPublicID,
                                                    @Nullable final String sSystemID)
  {
    // do not return a line break at the end! (JS variable assignment)
    final StringBuilder aSB = new StringBuilder (128);
    aSB.append ("').toString ();
  }

  /**
   * On XML document type.
   *
   * @param sQualifiedElementName
   *        Qualified name of the root element.
   * @param sPublicID
   *        Document type public ID
   * @param sSystemID
   *        Document type system ID
   */
  public void onDocumentType (@Nonnull final String sQualifiedElementName,
                              @Nullable final String sPublicID,
                              @Nullable final String sSystemID)
  {
    ValueEnforcer.notNull (sQualifiedElementName, "QualifiedElementName");

    final String sDocType = getDocTypeXMLRepresentation (m_eXMLVersion,
                                                         m_aXMLWriterSettings.getIncorrectCharacterHandling (),
                                                         sQualifiedElementName,
                                                         sPublicID,
                                                         sSystemID);
    _append (sDocType);
    newLine ();
  }

  /**
   * On processing instruction
   *
   * @param sTarget
   *        The target
   * @param sData
   *        The data (attributes as a string)
   */
  public void onProcessingInstruction (@Nonnull final String sTarget, @Nullable final String sData)
  {
    _append (PI_START)._append (sTarget);
    if (StringHelper.hasText (sData))
      _append (' ')._append (sData);
    _append (PI_END);
    newLine ();
  }

  /**
   * On entity reference.
   *
   * @param sEntityRef
   *        The reference (without '&' and ';' !!)
   */
  public void onEntityReference (@Nonnull final String sEntityRef)
  {
    _append (ER_START)._append (sEntityRef)._append (ER_END);
  }

  /**
   * Ignorable whitespace characters.
   *
   * @param aWhitespaces
   *        The whitespace character sequence
   */
  public void onContentElementWhitespace (@Nullable final CharSequence aWhitespaces)
  {
    if (StringHelper.hasText (aWhitespaces))
      _append (aWhitespaces.toString ());
  }

  /**
   * Comment node.
   *
   * @param sComment
   *        The comment text
   */
  public void onComment (@Nullable final String sComment)
  {
    if (StringHelper.hasText (sComment))
    {
      if (isThrowExceptionOnNestedComments ())
        if (sComment.contains (COMMENT_START) || sComment.contains (COMMENT_END))
          throw new IllegalArgumentException ("XML comment contains nested XML comment: " + sComment);

      _append (COMMENT_START)._append (sComment)._append (COMMENT_END);
    }
  }

  /**
   * XML text node.
   *
   * @param sText
   *        The contained text
   */
  public void onText (@Nullable final String sText)
  {
    onText (sText, true);
  }

  /**
   * XML text node.
   *
   * @param aText
   *        The contained text array
   * @param nOfs
   *        Offset into the array where to start
   * @param nLen
   *        Number of chars to use, starting from the provided offset.
   */
  public void onText (@Nonnull final char [] aText, @Nonnegative final int nOfs, @Nonnegative final int nLen)
  {
    onText (aText, nOfs, nLen, true);
  }

  /**
   * Text node.
   *
   * @param sText
   *        The contained text
   * @param bEscape
   *        If true the text should be XML masked (the default),
   *        false if not. The false case is especially
   *        interesting for HTML inline JS and CSS code.
   */
  public void onText (@Nullable final String sText, final boolean bEscape)
  {
    if (bEscape)
      _appendMasked (EXMLCharMode.TEXT, sText);
    else
      _append (sText);
  }

  /**
   * Text node.
   *
   * @param aText
   *        The contained text array
   * @param nOfs
   *        Offset into the array where to start
   * @param nLen
   *        Number of chars to use, starting from the provided offset.
   * @param bEscape
   *        If true the text should be XML masked (the default),
   *        false if not. The false case is especially
   *        interesting for HTML inline JS and CSS code.
   */
  public void onText (@Nonnull final char [] aText, @Nonnegative final int nOfs, @Nonnegative final int nLen, final boolean bEscape)
  {
    if (bEscape)
      _appendMasked (EXMLCharMode.TEXT, aText, nOfs, nLen);
    else
      _append (aText, nOfs, nLen);
  }

  /**
   * CDATA node.
   *
   * @param sText
   *        The contained text
   */
  public void onCDATA (@Nullable final String sText)
  {
    if (StringHelper.hasText (sText))
    {
      if (sText.indexOf (CDATA_END) >= 0)
      {
        // Split CDATA sections if they contain the illegal "]]>" marker
        final ICommonsList  aParts = StringHelper.getExploded (CDATA_END, sText);
        final int nParts = aParts.size ();
        for (int i = 0; i < nParts; ++i)
        {
          _append (CDATA_START);
          if (i > 0)
            _append ('>');
          _appendMasked (EXMLCharMode.CDATA, aParts.get (i));
          if (i < nParts - 1)
            _append ("]]");
          _append (CDATA_END);
        }
      }
      else
      {
        // No special handling required
        _append (CDATA_START)._appendMasked (EXMLCharMode.CDATA, sText)._append (CDATA_END);
      }
    }
  }

  public void elementStartOpen (@Nullable final String sNamespacePrefix, @Nonnull final String sTagName)
  {
    _append ('<');
    if (StringHelper.hasText (sNamespacePrefix))
    {
      // We have an element namespace prefix
      _appendMasked (EXMLCharMode.ELEMENT_NAME, sNamespacePrefix)._append (CXML.XML_PREFIX_NAMESPACE_SEP);
    }
    _appendMasked (EXMLCharMode.ELEMENT_NAME, sTagName);
  }

  public void elementAttr (@Nullable final String sAttrNamespacePrefix, @Nonnull final String sAttrName, @Nonnull final String sAttrValue)
  {
    _append (' ');
    if (StringHelper.hasText (sAttrNamespacePrefix))
    {
      // We have an attribute namespace prefix
      _append (sAttrNamespacePrefix)._append (CXML.XML_PREFIX_NAMESPACE_SEP);
    }
    _appendMasked (EXMLCharMode.ATTRIBUTE_NAME, sAttrName)._append ('=')._appendAttrValue (sAttrValue);
  }

  public void elementStartClose (@Nonnull final EXMLSerializeBracketMode eBracketMode)
  {
    if (eBracketMode.isSelfClosed ())
    {
      // Note: according to HTML compatibility guideline a space should be added
      // before the self-closing
      _append (m_aXMLWriterSettings.isSpaceOnSelfClosedElement () ? " />" : "/>");
    }
    else
    {
      // Either "open close" or "open only"
      _append ('>');
    }
  }

  /**
   * Start of an element.
   *
   * @param sNamespacePrefix
   *        Optional namespace prefix. May be null.
   * @param sTagName
   *        Tag name
   * @param aAttrs
   *        Optional set of attributes.
   * @param eBracketMode
   *        Bracket mode to use. Never null.
   */
  public void onElementStart (@Nullable final String sNamespacePrefix,
                              @Nonnull final String sTagName,
                              @Nullable final Map  aAttrs,
                              @Nonnull final EXMLSerializeBracketMode eBracketMode)
  {
    elementStartOpen (sNamespacePrefix, sTagName);

    if (aAttrs != null && !aAttrs.isEmpty ())
    {
      if (m_bOrderAttributesAndNamespaces)
      {
        // first separate in NS and non-NS attributes
        // Sort namespace attributes by assigned prefix
        final ICommonsSortedMap  aNamespaceAttrs = new CommonsTreeMap <> (CXML.getComparatorQNameForNamespacePrefix ());
        final ICommonsSortedMap  aNonNamespaceAttrs = new CommonsTreeMap <> (CXML.getComparatorQNameNamespaceURIBeforeLocalPart ());
        for (final Map.Entry  aEntry : aAttrs.entrySet ())
        {
          final QName aAttrName = aEntry.getKey ();
          if (XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals (aAttrName.getNamespaceURI ()))
            aNamespaceAttrs.put (aAttrName, aEntry.getValue ());
          else
            aNonNamespaceAttrs.put (aAttrName, aEntry.getValue ());
        }
        // emit namespace attributes first
        for (final Map.Entry  aEntry : aNamespaceAttrs.entrySet ())
        {
          final QName aAttrName = aEntry.getKey ();
          elementAttr (aAttrName.getPrefix (), aAttrName.getLocalPart (), aEntry.getValue ());
        }
        // emit non-namespace attributes afterwards
        for (final Map.Entry  aEntry : aNonNamespaceAttrs.entrySet ())
        {
          final QName aAttrName = aEntry.getKey ();
          elementAttr (aAttrName.getPrefix (), aAttrName.getLocalPart (), aEntry.getValue ());
        }
      }
      else
      {
        // assuming that the order of the passed attributes is consistent!
        // Emit all attributes
        for (final Map.Entry  aEntry : aAttrs.entrySet ())
        {
          final QName aAttrName = aEntry.getKey ();
          elementAttr (aAttrName.getPrefix (), aAttrName.getLocalPart (), aEntry.getValue ());
        }
      }
    }

    elementStartClose (eBracketMode);
  }

  /**
   * End of an element.
   *
   * @param sNamespacePrefix
   *        Optional namespace prefix. May be null.
   * @param sTagName
   *        Tag name
   * @param eBracketMode
   *        Bracket mode to use. Never null.
   */
  public void onElementEnd (@Nullable final String sNamespacePrefix,
                            @Nonnull final String sTagName,
                            @Nonnull final EXMLSerializeBracketMode eBracketMode)
  {
    if (eBracketMode.isOpenClose ())
    {
      _append ("');
    }
  }

  public void flush () throws IOException
  {
    m_aWriter.flush ();
  }

  public void close () throws IOException
  {
    m_aWriter.close ();
  }

  @Override
  public String toString ()
  {
    return new ToStringGenerator (this).append ("Writer", m_aWriter)
                                       .append ("XMLWriterSettings", m_aXMLWriterSettings)
                                       .append ("XMLVersion", m_eXMLVersion)
                                       .append ("AttrValueBoundary", m_cAttrValueBoundary)
                                       .append ("AttrValueCharMode", m_eAttrValueCharMode)
                                       .getToString ();
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy