1 package org.jsoup.nodes; 2 3 import org.jsoup.SerializationException; 4 import org.jsoup.helper.Validate; 5 import org.jsoup.internal.Normalizer; 6 import org.jsoup.internal.StringUtil; 7 import org.jsoup.nodes.Document.OutputSettings.Syntax; 8 import org.jspecify.annotations.Nullable; 9 10 import java.io.IOException; 11 import java.util.Arrays; 12 import java.util.Map; 13 import java.util.regex.Pattern; 14 15 /** 16 A single key + value attribute. (Only used for presentation.) 17 */ 18 public class Attribute implements Map.Entry<String, String>, Cloneable { 19 private static final String[] booleanAttributes = { 20 "allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled", 21 "formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize", 22 "noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected", 23 "sortable", "truespeed", "typemustmatch" 24 }; 25 26 private String key; 27 @Nullable private String val; 28 @Nullable Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface 29 30 /** 31 * Create a new attribute from unencoded (raw) key and value. 32 * @param key attribute key; case is preserved. 33 * @param value attribute value (may be null) 34 * @see #createFromEncoded 35 */ Attribute(String key, @Nullable String value)36 public Attribute(String key, @Nullable String value) { 37 this(key, value, null); 38 } 39 40 /** 41 * Create a new attribute from unencoded (raw) key and value. 42 * @param key attribute key; case is preserved. 43 * @param val attribute value (may be null) 44 * @param parent the containing Attributes (this Attribute is not automatically added to said Attributes) 45 * @see #createFromEncoded*/ Attribute(String key, @Nullable String val, @Nullable Attributes parent)46 public Attribute(String key, @Nullable String val, @Nullable Attributes parent) { 47 Validate.notNull(key); 48 key = key.trim(); 49 Validate.notEmpty(key); // trimming could potentially make empty, so validate here 50 this.key = key; 51 this.val = val; 52 this.parent = parent; 53 } 54 55 /** 56 Get the attribute key. 57 @return the attribute key 58 */ 59 @Override getKey()60 public String getKey() { 61 return key; 62 } 63 64 /** 65 Set the attribute key; case is preserved. 66 @param key the new key; must not be null 67 */ setKey(String key)68 public void setKey(String key) { 69 Validate.notNull(key); 70 key = key.trim(); 71 Validate.notEmpty(key); // trimming could potentially make empty, so validate here 72 if (parent != null) { 73 int i = parent.indexOfKey(this.key); 74 if (i != Attributes.NotFound) { 75 String oldKey = parent.keys[i]; 76 parent.keys[i] = key; 77 78 // if tracking source positions, update the key in the range map 79 Map<String, Range.AttributeRange> ranges = parent.getRanges(); 80 if (ranges != null) { 81 Range.AttributeRange range = ranges.remove(oldKey); 82 ranges.put(key, range); 83 } 84 } 85 } 86 this.key = key; 87 } 88 89 /** 90 Get the attribute value. Will return an empty string if the value is not set. 91 @return the attribute value 92 */ 93 @Override getValue()94 public String getValue() { 95 return Attributes.checkNotNull(val); 96 } 97 98 /** 99 * Check if this Attribute has a value. Set boolean attributes have no value. 100 * @return if this is a boolean attribute / attribute without a value 101 */ hasDeclaredValue()102 public boolean hasDeclaredValue() { 103 return val != null; 104 } 105 106 /** 107 Set the attribute value. 108 @param val the new attribute value; may be null (to set an enabled boolean attribute) 109 @return the previous value (if was null; an empty string) 110 */ setValue(@ullable String val)111 @Override public String setValue(@Nullable String val) { 112 String oldVal = this.val; 113 if (parent != null) { 114 int i = parent.indexOfKey(this.key); 115 if (i != Attributes.NotFound) { 116 oldVal = parent.get(this.key); // trust the container more 117 parent.vals[i] = val; 118 } 119 } 120 this.val = val; 121 return Attributes.checkNotNull(oldVal); 122 } 123 124 /** 125 Get the HTML representation of this attribute; e.g. {@code href="index.html"}. 126 @return HTML 127 */ html()128 public String html() { 129 StringBuilder sb = StringUtil.borrowBuilder(); 130 131 try { 132 html(sb, (new Document("")).outputSettings()); 133 } catch(IOException exception) { 134 throw new SerializationException(exception); 135 } 136 return StringUtil.releaseBuilder(sb); 137 } 138 139 /** 140 Get the source ranges (start to end positions) in the original input source from which this attribute's <b>name</b> 141 and <b>value</b> were parsed. 142 <p>Position tracking must be enabled prior to parsing the content.</p> 143 @return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range 144 was not tracked. 145 @see org.jsoup.parser.Parser#setTrackPosition(boolean) 146 @see Attributes#sourceRange(String) 147 @see Node#sourceRange() 148 @see Element#endSourceRange() 149 @since 1.17.1 150 */ sourceRange()151 public Range.AttributeRange sourceRange() { 152 if (parent == null) return Range.AttributeRange.UntrackedAttr; 153 return parent.sourceRange(key); 154 } 155 html(Appendable accum, Document.OutputSettings out)156 protected void html(Appendable accum, Document.OutputSettings out) throws IOException { 157 html(key, val, accum, out); 158 } 159 html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out)160 protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException { 161 key = getValidKey(key, out.syntax()); 162 if (key == null) return; // can't write it :( 163 htmlNoValidate(key, val, accum, out); 164 } 165 htmlNoValidate(String key, @Nullable String val, Appendable accum, Document.OutputSettings out)166 static void htmlNoValidate(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException { 167 // structured like this so that Attributes can check we can write first, so it can add whitespace correctly 168 accum.append(key); 169 if (!shouldCollapseAttribute(key, val, out)) { 170 accum.append("=\""); 171 Entities.escape(accum, Attributes.checkNotNull(val) , out, true, false, false, false); 172 accum.append('"'); 173 } 174 } 175 176 private static final Pattern xmlKeyValid = Pattern.compile("[a-zA-Z_:][-a-zA-Z0-9_:.]*"); 177 private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]"); 178 private static final Pattern htmlKeyValid = Pattern.compile("[^\\x00-\\x1f\\x7f-\\x9f \"'/=]+"); 179 private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]"); 180 getValidKey(String key, Syntax syntax)181 @Nullable public static String getValidKey(String key, Syntax syntax) { 182 // we consider HTML attributes to always be valid. XML checks key validity 183 if (syntax == Syntax.xml && !xmlKeyValid.matcher(key).matches()) { 184 key = xmlKeyReplace.matcher(key).replaceAll(""); 185 return xmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced 186 } 187 else if (syntax == Syntax.html && !htmlKeyValid.matcher(key).matches()) { 188 key = htmlKeyReplace.matcher(key).replaceAll(""); 189 return htmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced 190 } 191 return key; 192 } 193 194 /** 195 Get the string representation of this attribute, implemented as {@link #html()}. 196 @return string 197 */ 198 @Override toString()199 public String toString() { 200 return html(); 201 } 202 203 /** 204 * Create a new Attribute from an unencoded key and a HTML attribute encoded value. 205 * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars. 206 * @param encodedValue HTML attribute encoded value 207 * @return attribute 208 */ createFromEncoded(String unencodedKey, String encodedValue)209 public static Attribute createFromEncoded(String unencodedKey, String encodedValue) { 210 String value = Entities.unescape(encodedValue, true); 211 return new Attribute(unencodedKey, value, null); // parent will get set when Put 212 } 213 isDataAttribute()214 protected boolean isDataAttribute() { 215 return isDataAttribute(key); 216 } 217 isDataAttribute(String key)218 protected static boolean isDataAttribute(String key) { 219 return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length(); 220 } 221 222 /** 223 * Collapsible if it's a boolean attribute and value is empty or same as name 224 * 225 * @param out output settings 226 * @return Returns whether collapsible or not 227 */ shouldCollapseAttribute(Document.OutputSettings out)228 protected final boolean shouldCollapseAttribute(Document.OutputSettings out) { 229 return shouldCollapseAttribute(key, val, out); 230 } 231 232 // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out)233 protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) { 234 return ( 235 out.syntax() == Syntax.html && 236 (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key))); 237 } 238 239 /** 240 * Checks if this attribute name is defined as a boolean attribute in HTML5 241 */ isBooleanAttribute(final String key)242 public static boolean isBooleanAttribute(final String key) { 243 return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0; 244 } 245 246 @Override equals(@ullable Object o)247 public boolean equals(@Nullable Object o) { // note parent not considered 248 if (this == o) return true; 249 if (o == null || getClass() != o.getClass()) return false; 250 Attribute attribute = (Attribute) o; 251 if (key != null ? !key.equals(attribute.key) : attribute.key != null) return false; 252 return val != null ? val.equals(attribute.val) : attribute.val == null; 253 } 254 255 @Override hashCode()256 public int hashCode() { // note parent not considered 257 int result = key != null ? key.hashCode() : 0; 258 result = 31 * result + (val != null ? val.hashCode() : 0); 259 return result; 260 } 261 262 @Override clone()263 public Attribute clone() { 264 try { 265 return (Attribute) super.clone(); 266 } catch (CloneNotSupportedException e) { 267 throw new RuntimeException(e); 268 } 269 } 270 } 271