001package org.jsoup.nodes; 002 003import org.jsoup.helper.Validate; 004import org.jsoup.internal.Normalizer; 005import org.jsoup.internal.QuietAppendable; 006import org.jsoup.internal.SharedConstants; 007import org.jsoup.internal.StringUtil; 008import org.jsoup.nodes.Document.OutputSettings.Syntax; 009import org.jspecify.annotations.Nullable; 010 011import java.util.Arrays; 012import java.util.Map; 013import java.util.Objects; 014import java.util.regex.Pattern; 015 016/** 017 Represents one attribute as a key/value pair. 018 Keys preserve case and are trimmed of surrounding ASCII whitespace when created or changed. 019 */ 020public class Attribute implements Map.Entry<String, String>, Cloneable { 021 private static final String[] booleanAttributes = { 022 "allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled", 023 "formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize", 024 "noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected", 025 "sortable", "truespeed", "typemustmatch" 026 }; 027 028 private String key; 029 @Nullable private String val; 030 @Nullable Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface 031 032 /** 033 * Create a new attribute from unencoded (raw) key and value. 034 * @param key attribute key 035 * @param value attribute value (may be null) 036 * @see #createFromEncoded 037 */ 038 public Attribute(String key, @Nullable String value) { 039 this(key, value, null); 040 } 041 042 /** 043 * Create a new attribute from unencoded (raw) key and value. 044 * @param key attribute key 045 * @param val attribute value (may be null) 046 * @param parent the containing Attributes (this Attribute is not automatically added to said Attributes) 047 * @see #createFromEncoded*/ 048 public Attribute(String key, @Nullable String val, @Nullable Attributes parent) { 049 Validate.notNull(key); 050 key = StringUtil.trimAsciiWhitespace(key); 051 Validate.notEmpty(key); // trimming could potentially make empty, so validate here 052 this.key = key; 053 this.val = val; 054 this.parent = parent; 055 } 056 057 /** 058 Get the attribute's key (aka name). 059 @return the attribute key 060 */ 061 @Override 062 public String getKey() { 063 return key; 064 } 065 066 /** 067 Set the attribute key. 068 @param key the new key; must not be null 069 */ 070 public void setKey(String key) { 071 Validate.notNull(key); 072 key = StringUtil.trimAsciiWhitespace(key); 073 Validate.notEmpty(key); // trimming could potentially make empty, so validate here 074 if (parent != null) { 075 int i = parent.indexOfKey(this.key); 076 if (i != Attributes.NotFound) { 077 parent.keys[i] = key; 078 // Source ranges are index-aligned in the parent, so a key update keeps the same range. 079 } 080 } 081 this.key = key; 082 } 083 084 /** 085 Get the attribute value. Will return an empty string if the value is not set. 086 @return the attribute value 087 */ 088 @Override 089 public String getValue() { 090 return Attributes.checkNotNull(val); 091 } 092 093 /** 094 * Check if this Attribute has a value. Set boolean attributes have no value. 095 * @return if this is a boolean attribute / attribute without a value 096 */ 097 public boolean hasDeclaredValue() { 098 return val != null; 099 } 100 101 /** 102 Set the attribute value. 103 @param val the new attribute value; may be null (to set an enabled boolean attribute) 104 @return the previous value (if was null; an empty string) 105 */ 106 @Override public String setValue(@Nullable String val) { 107 String oldVal = this.val; 108 if (parent != null) { 109 int i = parent.indexOfKey(this.key); 110 if (i != Attributes.NotFound) { 111 oldVal = parent.get(this.key); // trust the container more 112 parent.vals[i] = val; 113 } 114 } 115 this.val = val; 116 return Attributes.checkNotNull(oldVal); 117 } 118 119 /** 120 Get this attribute's key prefix, if it has one; else the empty string. 121 <p>For example, the attribute {@code og:title} has prefix {@code og}, and local {@code title}.</p> 122 123 @return the tag's prefix 124 @since 1.20.1 125 */ 126 public String prefix() { 127 int pos = key.indexOf(':'); 128 if (pos == -1) return ""; 129 else return key.substring(0, pos); 130 } 131 132 /** 133 Get this attribute's local name. The local name is the name without the prefix (if any). 134 <p>For example, the attribute key {@code og:title} has local name {@code title}.</p> 135 136 @return the tag's local name 137 @since 1.20.1 138 */ 139 public String localName() { 140 int pos = key.indexOf(':'); 141 if (pos == -1) return key; 142 else return key.substring(pos + 1); 143 } 144 145 /** 146 Get this attribute's namespace URI, if the attribute was prefixed with a defined namespace name. Otherwise, returns 147 the empty string. These will only be defined if using the XML parser. 148 @return the tag's namespace URI, or empty string if not defined 149 @since 1.20.1 150 */ 151 public String namespace() { 152 // set as el.attributes.userData(SharedConstants.XmlnsAttr + prefix, ns) 153 if (parent != null) { 154 String ns = (String) parent.userData(SharedConstants.XmlnsAttr + prefix()); 155 if (ns != null) 156 return ns; 157 } 158 return ""; 159 } 160 161 /** 162 Get the HTML representation of this attribute; e.g. {@code href="index.html"}. 163 @return HTML 164 */ 165 public String html() { 166 StringBuilder sb = StringUtil.borrowBuilder(); 167 html(QuietAppendable.wrap(sb), new Document.OutputSettings()); 168 return StringUtil.releaseBuilder(sb); 169 } 170 171 /** 172 Get the source ranges (start to end positions) in the original input source from which this attribute's <b>name</b> 173 and <b>value</b> were parsed. 174 <p>Position tracking must be enabled prior to parsing the content.</p> 175 @return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range 176 was not tracked. 177 @see org.jsoup.parser.Parser#setTrackPosition(boolean) 178 @see Attributes#sourceRange(String) 179 @see Node#sourceRange() 180 @see Element#endSourceRange() 181 @since 1.17.1 182 */ 183 public Range.AttributeRange sourceRange() { 184 if (parent == null) return Range.AttributeRange.UntrackedAttr; 185 return parent.sourceRange(key); 186 } 187 188 void html(QuietAppendable accum, Document.OutputSettings out) { 189 html(key, val, accum, out); 190 } 191 192 static void html(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) { 193 key = getValidKey(key, out.syntax()); 194 htmlNoValidate(key, val, accum, out); 195 } 196 197 static void htmlNoValidate(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) { 198 // structured like this so that Attributes can check we can write first, so it can add whitespace correctly 199 accum.append(key); 200 if (!shouldCollapseAttribute(key, val, out)) { 201 accum.append("=\""); 202 Entities.escape(accum, Attributes.checkNotNull(val), out, Entities.ForAttribute); // preserves whitespace 203 accum.append('"'); 204 } 205 } 206 207 private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]+"); 208 private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]+"); 209 210 /** 211 Get a valid key for the output syntax. Invalid character runs are replaced with {@code _}, and XML keys with 212 invalid starts are prefixed with {@code _}. 213 214 @param key the input key 215 @param syntax HTML or XML 216 @return a valid key for the output syntax 217 */ 218 public static String getValidKey(String key, Syntax syntax) { 219 if (key.isEmpty()) return "_"; 220 if (syntax == Syntax.xml && !isValidXmlKey(key)) { 221 key = xmlKeyReplace.matcher(key).replaceAll("_"); 222 if (!isValidXmlKeyStart(key.charAt(0))) 223 key = StringUtil.concat('_', key); 224 } else if (syntax == Syntax.html && !isValidHtmlKey(key)) { 225 key = htmlKeyReplace.matcher(key).replaceAll("_"); 226 } 227 return key; 228 } 229 230 // perf critical in html() so using manual scan vs regex: 231 // note that we aren't using anything in supplemental space, so OK to iter charAt 232 private static boolean isValidXmlKey(String key) { 233 // =~ [a-zA-Z_:][-a-zA-Z0-9_:.]* 234 final int length = key.length(); 235 if (length == 0) return false; 236 char c = key.charAt(0); 237 if (!isValidXmlKeyStart(c)) return false; 238 for (int i = 1; i < length; i++) { 239 c = key.charAt(i); 240 if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':' || c == '.')) 241 return false; 242 } 243 return true; 244 } 245 246 /** Check that the character can start an XML name */ 247 private static boolean isValidXmlKeyStart(char c) { 248 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == ':'; 249 } 250 251 private static boolean isValidHtmlKey(String key) { 252 // =~ [\x00-\x1f\x7f-\x9f "'/=]+ 253 final int length = key.length(); 254 if (length == 0) return false; 255 for (int i = 0; i < length; i++) { 256 char c = key.charAt(i); 257 if ((c <= 0x1f) || (c >= 0x7f && c <= 0x9f) || c == ' ' || c == '"' || c == '\'' || c == '/' || c == '=') 258 return false; 259 } 260 return true; 261 } 262 263 /** 264 Get the string representation of this attribute, implemented as {@link #html()}. 265 @return string 266 */ 267 @Override 268 public String toString() { 269 return html(); 270 } 271 272 /** 273 * Create a new Attribute from an unencoded key and a HTML attribute encoded value. 274 * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars. 275 * @param encodedValue HTML attribute encoded value 276 * @return attribute 277 */ 278 public static Attribute createFromEncoded(String unencodedKey, String encodedValue) { 279 String value = Entities.unescape(encodedValue, true); 280 return new Attribute(unencodedKey, value, null); // parent will get set when Put 281 } 282 283 protected boolean isDataAttribute() { 284 return isDataAttribute(key); 285 } 286 287 protected static boolean isDataAttribute(String key) { 288 return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length(); 289 } 290 291 // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others 292 protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) { 293 return (out.syntax() == Syntax.html && 294 (val == null || (val.isEmpty() || Normalizer.equalsIgnoreAsciiCase(val, key)) && Attribute.isBooleanAttribute(key))); 295 } 296 297 /** 298 * Checks if this attribute name is defined as a boolean attribute in HTML5 299 */ 300 public static boolean isBooleanAttribute(final String key) { 301 return Arrays.binarySearch(booleanAttributes, Normalizer.asciiLowerCase(key)) >= 0; 302 } 303 304 @Override 305 public boolean equals(@Nullable Object o) { // note parent not considered 306 if (this == o) return true; 307 if (o == null || getClass() != o.getClass()) return false; 308 Attribute attribute = (Attribute) o; 309 return Objects.equals(key, attribute.key) && Objects.equals(val, attribute.val); 310 } 311 312 @Override 313 public int hashCode() { // note parent not considered 314 return Objects.hash(key, val); 315 } 316 317 @Override 318 public Attribute clone() { 319 try { 320 return (Attribute) super.clone(); 321 } catch (CloneNotSupportedException e) { 322 throw new RuntimeException(e); 323 } 324 } 325}