org.jsoup.nodes.Attribute Maven / Gradle / Ivy
Go to download
SDK for dev_appserver (local development) with some of the dependencies shaded (repackaged)
package org.jsoup.nodes;
import org.jsoup.SerializationException;
import org.jsoup.helper.Validate;
import org.jsoup.internal.Normalizer;
import org.jsoup.internal.StringUtil;
import org.jsoup.nodes.Document.OutputSettings.Syntax;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
import java.util.regex.Pattern;
/**
A single key + value attribute. (Only used for presentation.)
*/
public class Attribute implements Map.Entry, Cloneable {
private static final String[] booleanAttributes = {
"allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled",
"formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize",
"noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected",
"sortable", "truespeed", "typemustmatch"
};
private String key;
@Nullable private String val;
@Nullable Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface
/**
* Create a new attribute from unencoded (raw) key and value.
* @param key attribute key; case is preserved.
* @param value attribute value (may be null)
* @see #createFromEncoded
*/
public Attribute(String key, @Nullable String value) {
this(key, value, null);
}
/**
* Create a new attribute from unencoded (raw) key and value.
* @param key attribute key; case is preserved.
* @param val attribute value (may be null)
* @param parent the containing Attributes (this Attribute is not automatically added to said Attributes)
* @see #createFromEncoded*/
public Attribute(String key, @Nullable String val, @Nullable Attributes parent) {
Validate.notNull(key);
key = key.trim();
Validate.notEmpty(key); // trimming could potentially make empty, so validate here
this.key = key;
this.val = val;
this.parent = parent;
}
/**
Get the attribute key.
@return the attribute key
*/
@Override
public String getKey() {
return key;
}
/**
Set the attribute key; case is preserved.
@param key the new key; must not be null
*/
public void setKey(String key) {
Validate.notNull(key);
key = key.trim();
Validate.notEmpty(key); // trimming could potentially make empty, so validate here
if (parent != null) {
int i = parent.indexOfKey(this.key);
if (i != Attributes.NotFound) {
String oldKey = parent.keys[i];
parent.keys[i] = key;
// if tracking source positions, update the key in the range map
Map ranges = parent.getRanges();
if (ranges != null) {
Range.AttributeRange range = ranges.remove(oldKey);
ranges.put(key, range);
}
}
}
this.key = key;
}
/**
Get the attribute value. Will return an empty string if the value is not set.
@return the attribute value
*/
@Override
public String getValue() {
return Attributes.checkNotNull(val);
}
/**
* Check if this Attribute has a value. Set boolean attributes have no value.
* @return if this is a boolean attribute / attribute without a value
*/
public boolean hasDeclaredValue() {
return val != null;
}
/**
Set the attribute value.
@param val the new attribute value; may be null (to set an enabled boolean attribute)
@return the previous value (if was null; an empty string)
*/
@Override public String setValue(@Nullable String val) {
String oldVal = this.val;
if (parent != null) {
int i = parent.indexOfKey(this.key);
if (i != Attributes.NotFound) {
oldVal = parent.get(this.key); // trust the container more
parent.vals[i] = val;
}
}
this.val = val;
return Attributes.checkNotNull(oldVal);
}
/**
Get the HTML representation of this attribute; e.g. {@code href="index.html"}.
@return HTML
*/
public String html() {
StringBuilder sb = StringUtil.borrowBuilder();
try {
html(sb, (new Document("")).outputSettings());
} catch(IOException exception) {
throw new SerializationException(exception);
}
return StringUtil.releaseBuilder(sb);
}
/**
Get the source ranges (start to end positions) in the original input source from which this attribute's name
and value were parsed.
Position tracking must be enabled prior to parsing the content.
@return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range
was not tracked.
@see org.jsoup.parser.Parser#setTrackPosition(boolean)
@see Attributes#sourceRange(String)
@see Node#sourceRange()
@see Element#endSourceRange()
@since 1.17.1
*/
public Range.AttributeRange sourceRange() {
if (parent == null) return Range.AttributeRange.UntrackedAttr;
return parent.sourceRange(key);
}
protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
html(key, val, accum, out);
}
protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
key = getValidKey(key, out.syntax());
if (key == null) return; // can't write it :(
htmlNoValidate(key, val, accum, out);
}
static void htmlNoValidate(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
// structured like this so that Attributes can check we can write first, so it can add whitespace correctly
accum.append(key);
if (!shouldCollapseAttribute(key, val, out)) {
accum.append("=\"");
Entities.escape(accum, Attributes.checkNotNull(val) , out, true, false, false, false);
accum.append('"');
}
}
private static final Pattern xmlKeyValid = Pattern.compile("[a-zA-Z_:][-a-zA-Z0-9_:.]*");
private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]");
private static final Pattern htmlKeyValid = Pattern.compile("[^\\x00-\\x1f\\x7f-\\x9f \"'/=]+");
private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]");
@Nullable public static String getValidKey(String key, Syntax syntax) {
// we consider HTML attributes to always be valid. XML checks key validity
if (syntax == Syntax.xml && !xmlKeyValid.matcher(key).matches()) {
key = xmlKeyReplace.matcher(key).replaceAll("");
return xmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced
}
else if (syntax == Syntax.html && !htmlKeyValid.matcher(key).matches()) {
key = htmlKeyReplace.matcher(key).replaceAll("");
return htmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced
}
return key;
}
/**
Get the string representation of this attribute, implemented as {@link #html()}.
@return string
*/
@Override
public String toString() {
return html();
}
/**
* Create a new Attribute from an unencoded key and a HTML attribute encoded value.
* @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars.
* @param encodedValue HTML attribute encoded value
* @return attribute
*/
public static Attribute createFromEncoded(String unencodedKey, String encodedValue) {
String value = Entities.unescape(encodedValue, true);
return new Attribute(unencodedKey, value, null); // parent will get set when Put
}
protected boolean isDataAttribute() {
return isDataAttribute(key);
}
protected static boolean isDataAttribute(String key) {
return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length();
}
/**
* Collapsible if it's a boolean attribute and value is empty or same as name
*
* @param out output settings
* @return Returns whether collapsible or not
*/
protected final boolean shouldCollapseAttribute(Document.OutputSettings out) {
return shouldCollapseAttribute(key, val, out);
}
// collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others
protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
return (
out.syntax() == Syntax.html &&
(val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
}
/**
* Checks if this attribute name is defined as a boolean attribute in HTML5
*/
public static boolean isBooleanAttribute(final String key) {
return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0;
}
@Override
public boolean equals(@Nullable Object o) { // note parent not considered
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Attribute attribute = (Attribute) o;
if (key != null ? !key.equals(attribute.key) : attribute.key != null) return false;
return val != null ? val.equals(attribute.val) : attribute.val == null;
}
@Override
public int hashCode() { // note parent not considered
int result = key != null ? key.hashCode() : 0;
result = 31 * result + (val != null ? val.hashCode() : 0);
return result;
}
@Override
public Attribute clone() {
try {
return (Attribute) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}