groovy.xml.MarkupBuilder Maven / Gradle / Ivy
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 groovy.xml;
import groovy.namespace.QName;
import groovy.util.BuilderSupport;
import groovy.util.IndentPrinter;
import groovy.xml.markupsupport.DoubleQuoteFilter;
import groovy.xml.markupsupport.SingleQuoteFilter;
import groovy.xml.markupsupport.StandardXmlAttributeFilter;
import groovy.xml.markupsupport.StandardXmlFilter;
import org.codehaus.groovy.runtime.StringGroovyMethods;
import java.io.PrintWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import static org.codehaus.groovy.vmplugin.v8.PluginDefaultGroovyMethods.orOptional;
/**
* A helper class for creating XML or HTML markup.
* The builder supports various 'pretty printed' formats.
*
* Example:
*
* new MarkupBuilder().root {
* a( a1:'one' ) {
* b { mkp.yield( '3 {@code <} 5' ) }
* c( a2:'two', 'blah' )
* }
* }
*
* Will print the following to System.out:
*
* <root>
* <a a1='one'>
* <b>3 < 5</b>
* <c a2='two'>blah</c>
* </a>
* </root>
*
* Notes:
*
* mkp
is a special namespace used to escape
* away from the normal building mode of the builder and get access
* to helper markup methods such as 'yield' and 'yieldUnescaped'.
* See the javadoc for {@link #getMkp()} for further details.
* - Note that tab, newline and carriage return characters are escaped within attributes, i.e. will become 	, and respectively
*
*/
public class MarkupBuilder extends BuilderSupport {
public enum CharFilter { XML_STRICT, XML_ALL, NONE }
private IndentPrinter out;
private boolean nospace;
private int state;
private boolean nodeIsEmpty = true;
private boolean useDoubleQuotes = false;
private boolean omitNullAttributes = false;
private boolean omitEmptyAttributes = false;
private boolean expandEmptyElements = false;
private boolean escapeAttributes = true;
private List>> additionalFilters = null;
public List>> getAdditionalFilters() {
return additionalFilters;
}
public void setAdditionalFilters(List>> additionalFilters) {
this.additionalFilters = additionalFilters;
}
/**
* Returns the escapeAttributes property value.
*
* @return the escapeAttributes property value
* @see #setEscapeAttributes(boolean)
*/
public boolean isEscapeAttributes() {
return escapeAttributes;
}
/**
* Defaults to true. If set to false then you must escape any special
* characters within attribute values such as '&', '<', CR/LF, single
* and double quotes etc. manually as needed. The builder will not guard
* against producing invalid XML when in this mode and the output may not
* be able to be parsed/round-tripped but it does give you full control when
* producing for instance HTML output.
*
* @param escapeAttributes the new value
*/
public void setEscapeAttributes(boolean escapeAttributes) {
this.escapeAttributes = escapeAttributes;
}
/**
* Prints markup to System.out
*
* @see IndentPrinter#IndentPrinter()
*/
public MarkupBuilder() {
this(new IndentPrinter());
}
/**
* Sends markup to the given PrintWriter
*
* @param pw the PrintWriter to use
* @see IndentPrinter#IndentPrinter(Writer)
*/
public MarkupBuilder(PrintWriter pw) {
this(new IndentPrinter(pw));
}
/**
* Sends markup to the given Writer but first wrapping it in a PrintWriter
*
* @param writer the writer to use
* @see IndentPrinter#IndentPrinter(Writer)
*/
public MarkupBuilder(Writer writer) {
this(new IndentPrinter(new PrintWriter(writer)));
}
/**
* Sends markup to the given IndentPrinter. Use this option if you want
* to customize the indent used or provide your own IndentPrinter.
*
* @param out the IndentPrinter to use
*/
public MarkupBuilder(IndentPrinter out) {
this.out = out;
}
/**
* Returns true
if attribute values are output with
* double quotes; false
if single quotes are used.
* By default, single quotes are used.
*
* @return true if double quotes are used for attributes
*/
public boolean getDoubleQuotes() {
return this.useDoubleQuotes;
}
/**
* Sets whether the builder outputs attribute values in double
* quotes or single quotes.
*
* @param useDoubleQuotes If this parameter is true
,
* double quotes are used; otherwise, single quotes are.
*/
public void setDoubleQuotes(boolean useDoubleQuotes) {
this.useDoubleQuotes = useDoubleQuotes;
}
/**
* Determine whether null attributes will appear in the produced markup.
*
* @return true
, if null attributes will be
* removed from the resulting markup.
*/
public boolean isOmitNullAttributes() {
return omitNullAttributes;
}
/**
* Allows null attributes to be removed from the generated markup.
*
* @param omitNullAttributes if true
, null
* attributes will not be included in the resulting markup.
* If false
null attributes will be included in the
* markup as empty strings regardless of the omitEmptyAttribute
* setting. Defaults to false
.
*/
public void setOmitNullAttributes(boolean omitNullAttributes) {
this.omitNullAttributes = omitNullAttributes;
}
/**
* Determine whether empty attributes will appear in the produced markup.
*
* @return true
, if empty attributes will be
* removed from the resulting markup.
*/
public boolean isOmitEmptyAttributes() {
return omitEmptyAttributes;
}
/**
* Allows empty attributes to be removed from the generated markup.
*
* @param omitEmptyAttributes if true
, empty
* attributes will not be included in the resulting markup.
* Defaults to false
.
*/
public void setOmitEmptyAttributes(boolean omitEmptyAttributes) {
this.omitEmptyAttributes = omitEmptyAttributes;
}
/**
* Whether empty elements are expanded from <tagName/> to <tagName></tagName>.
*
* @return true
, if empty elements will be represented by an opening tag
* followed immediately by a closing tag.
*/
public boolean isExpandEmptyElements() {
return expandEmptyElements;
}
/**
* Whether empty elements are expanded from <tagName/> to <tagName></tagName>.
*
* @param expandEmptyElements if true
, empty
* elements will be represented by an opening tag
* followed immediately by a closing tag.
* Defaults to false
.
*/
public void setExpandEmptyElements(boolean expandEmptyElements) {
this.expandEmptyElements = expandEmptyElements;
}
protected IndentPrinter getPrinter() {
return this.out;
}
@Override
protected void setParent(Object parent, Object child) {
}
/**
* Property that may be called from within your builder closure to access
* helper methods, namely {@link MarkupBuilderHelper#yield(String)},
* {@link MarkupBuilderHelper#yieldUnescaped(String)},
* {@link MarkupBuilderHelper#pi(Map)},
* {@link MarkupBuilderHelper#xmlDeclaration(Map)} and
* {@link MarkupBuilderHelper#comment(String)}.
*
* @return this MarkupBuilder
*/
public MarkupBuilderHelper getMkp() {
return new MarkupBuilderHelper(this);
}
/**
* Produce an XML processing instruction in the output.
* For example:
*
* mkp.pi("xml-stylesheet":[href:"mystyle.css", type:"text/css"])
*
*
* @param args a map with a single entry whose key is the name of the
* processing instruction and whose value is the attributes
* for the processing instruction.
*/
void pi(Map> args) {
Iterator>> iterator = args.entrySet().iterator();
if (iterator.hasNext()) {
Map.Entry> mapEntry = iterator.next();
createNode("?" + mapEntry.getKey(), mapEntry.getValue());
state = 2;
out.println("?>");
}
}
void yield(String value, boolean escaping) {
if (state == 1) {
state = 2;
this.nodeIsEmpty = false;
out.print(">");
}
if (state == 0 || state == 2 || state == 3) {
out.print(escaping ? escapeElementContent(value) : value);
}
}
@Override
protected Object createNode(Object name) {
Object theName = getName(name);
toState(1, theName);
this.nodeIsEmpty = true;
return theName;
}
@Override
protected Object createNode(Object name, Object value) {
Object theName = getName(name);
if (value == null) {
return createNode(theName);
} else {
toState(2, theName);
this.nodeIsEmpty = false;
out.print(">");
out.print(escapeElementContent(value.toString()));
return theName;
}
}
@Override
protected Object createNode(Object name, Map attributes, Object value) {
Object theName = getName(name);
toState(1, theName);
for (Object p : attributes.entrySet()) {
Map.Entry entry = (Map.Entry) p;
Object attributeValue = entry.getValue();
boolean skipNull = attributeValue == null && omitNullAttributes;
boolean skipEmpty = attributeValue != null && omitEmptyAttributes &&
attributeValue.toString().length() == 0;
if (!skipNull && !skipEmpty) {
out.print(" ");
// Output the attribute name,
print(entry.getKey().toString());
// Output the attribute value within quotes. Use whichever
// type of quotes are currently configured.
out.print(useDoubleQuotes ? "=\"" : "='");
print(attributeValue == null ? "" : escapeAttributes ? escapeAttributeValue(attributeValue.toString()) : attributeValue.toString());
out.print(useDoubleQuotes ? "\"" : "'");
}
}
if (value != null) {
this.yield(value.toString(), true);
} else {
nodeIsEmpty = true;
}
return theName;
}
@Override
protected Object createNode(Object name, Map attributes) {
return createNode(name, attributes, null);
}
@Override
protected void nodeCompleted(Object parent, Object node) {
toState(3, node);
out.flush();
}
protected void print(Object node) {
out.print(node == null ? "null" : node.toString());
}
@Override
protected Object getName(String methodName) {
return super.getName(methodName);
}
/**
* Escapes a string so that it can be used directly as an XML
* attribute value.
*
* @param value The string to escape.
* @return A new string in which all characters that require escaping
* have been replaced with the corresponding XML entities.
* @see #escapeXmlValue(String, boolean)
*/
private String escapeAttributeValue(String value) {
return escapeXmlValue(value, true);
}
/**
* Escapes a string so that it can be used directly in XML element
* content.
*
* @param value The string to escape.
* @return A new string in which all characters that require escaping
* have been replaced with the corresponding XML entities.
* @see #escapeXmlValue(String, boolean)
*/
private String escapeElementContent(String value) {
return escapeXmlValue(value, false);
}
/**
* Escapes a string so that it can be used in XML text successfully.
* It replaces the following characters with the corresponding XML
* entities:
*
* - & as &
* - < as <
* - > as >
*
* If the string is to be added as an attribute value, these
* characters are also escaped:
*
* - ' as '
*
*
* @param value The string to escape.
* @param isAttrValue true
if the string is to be used
* as an attribute value, otherwise false
.
* @return A new string in which all characters that require escaping
* have been replaced with the corresponding XML entities.
*/
private String escapeXmlValue(String value, boolean isAttrValue) {
if (value == null) {
throw new IllegalArgumentException();
}
List>> transforms = new ArrayList<>();
transforms.add(new DefaultXmlEscapingFunction(isAttrValue, useDoubleQuotes));
if (additionalFilters != null) {
transforms.addAll(additionalFilters);
}
return StringGroovyMethods.collectReplacements(value, transforms);
}
public static class DefaultXmlEscapingFunction implements Function> {
private final boolean isAttrValue;
private final Function> stdFilter = new StandardXmlFilter();
private final Function> attrFilter = new StandardXmlAttributeFilter();
private final Function> quoteFilter;
public DefaultXmlEscapingFunction(boolean isAttrValue, boolean useDoubleQuotes) {
this.isAttrValue = isAttrValue;
this.quoteFilter = useDoubleQuotes ? new DoubleQuoteFilter() : new SingleQuoteFilter();
}
@Override
public Optional apply(Character ch) {
return orOptional(stdFilter.apply(ch),
() -> {
if (isAttrValue) {
return orOptional(attrFilter.apply(ch), () -> quoteFilter.apply(ch));
}
return Optional.empty();
}
);
}
}
private void toState(int next, Object name) {
switch (state) {
case 0:
switch (next) {
case 1:
case 2:
out.print("<");
print(name);
break;
case 3:
throw new Error();
}
break;
case 1:
switch (next) {
case 1:
case 2:
out.print(">");
if (nospace) {
nospace = false;
} else {
out.println();
out.incrementIndent();
out.printIndent();
}
out.print("<");
print(name);
break;
case 3:
if (nodeIsEmpty) {
if (expandEmptyElements) {
out.print(">");
print(name);
out.print(">");
} else {
out.print(" />");
}
}
break;
}
break;
case 2:
switch (next) {
case 1:
case 2:
if (!nodeIsEmpty) {
out.println();
out.incrementIndent();
out.printIndent();
}
out.print("<");
print(name);
break;
case 3:
out.print("");
print(name);
out.print(">");
break;
}
break;
case 3:
switch (next) {
case 1:
case 2:
if (nospace) {
nospace = false;
} else {
out.println();
out.printIndent();
}
out.print("<");
print(name);
break;
case 3:
if (nospace) {
nospace = false;
} else {
out.println();
out.decrementIndent();
out.printIndent();
}
out.print("");
print(name);
out.print(">");
break;
}
break;
}
state = next;
}
private static Object getName(Object name) {
if (name instanceof QName) {
return ((QName) name).getQualifiedName();
}
return name;
}
}