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.io.IOException; 012import java.util.Arrays; 013import java.util.Map; 014import java.util.Objects; 015import java.util.regex.Pattern; 016 017/** 018 A single key + value attribute. (Only used for presentation.) 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; case is preserved. 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; case is preserved. 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 = key.trim(); 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; case is preserved. 068 @param key the new key; must not be null 069 */ 070 public void setKey(String key) { 071 Validate.notNull(key); 072 key = key.trim(); 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 String oldKey = parent.keys[i]; 078 parent.keys[i] = key; 079 080 // if tracking source positions, update the key in the range map 081 Map<String, Range.AttributeRange> ranges = parent.getRanges(); 082 if (ranges != null) { 083 Range.AttributeRange range = ranges.remove(oldKey); 084 ranges.put(key, range); 085 } 086 } 087 } 088 this.key = key; 089 } 090 091 /** 092 Get the attribute value. Will return an empty string if the value is not set. 093 @return the attribute value 094 */ 095 @Override 096 public String getValue() { 097 return Attributes.checkNotNull(val); 098 } 099 100 /** 101 * Check if this Attribute has a value. Set boolean attributes have no value. 102 * @return if this is a boolean attribute / attribute without a value 103 */ 104 public boolean hasDeclaredValue() { 105 return val != null; 106 } 107 108 /** 109 Set the attribute value. 110 @param val the new attribute value; may be null (to set an enabled boolean attribute) 111 @return the previous value (if was null; an empty string) 112 */ 113 @Override public String setValue(@Nullable String val) { 114 String oldVal = this.val; 115 if (parent != null) { 116 int i = parent.indexOfKey(this.key); 117 if (i != Attributes.NotFound) { 118 oldVal = parent.get(this.key); // trust the container more 119 parent.vals[i] = val; 120 } 121 } 122 this.val = val; 123 return Attributes.checkNotNull(oldVal); 124 } 125 126 /** 127 Get this attribute's key prefix, if it has one; else the empty string. 128 <p>For example, the attribute {@code og:title} has prefix {@code og}, and local {@code title}.</p> 129 130 @return the tag's prefix 131 @since 1.20.1 132 */ 133 public String prefix() { 134 int pos = key.indexOf(':'); 135 if (pos == -1) return ""; 136 else return key.substring(0, pos); 137 } 138 139 /** 140 Get this attribute's local name. The local name is the name without the prefix (if any). 141 <p>For example, the attribute key {@code og:title} has local name {@code title}.</p> 142 143 @return the tag's local name 144 @since 1.20.1 145 */ 146 public String localName() { 147 int pos = key.indexOf(':'); 148 if (pos == -1) return key; 149 else return key.substring(pos + 1); 150 } 151 152 /** 153 Get this attribute's namespace URI, if the attribute was prefixed with a defined namespace name. Otherwise, returns 154 the empty string. These will only be defined if using the XML parser. 155 @return the tag's namespace URI, or empty string if not defined 156 @since 1.20.1 157 */ 158 public String namespace() { 159 // set as el.attributes.userData(SharedConstants.XmlnsAttr + prefix, ns) 160 if (parent != null) { 161 String ns = (String) parent.userData(SharedConstants.XmlnsAttr + prefix()); 162 if (ns != null) 163 return ns; 164 } 165 return ""; 166 } 167 168 /** 169 Get the HTML representation of this attribute; e.g. {@code href="index.html"}. 170 @return HTML 171 */ 172 public String html() { 173 StringBuilder sb = StringUtil.borrowBuilder(); 174 html(QuietAppendable.wrap(sb), new Document.OutputSettings()); 175 return StringUtil.releaseBuilder(sb); 176 } 177 178 /** 179 Get the source ranges (start to end positions) in the original input source from which this attribute's <b>name</b> 180 and <b>value</b> were parsed. 181 <p>Position tracking must be enabled prior to parsing the content.</p> 182 @return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range 183 was not tracked. 184 @see org.jsoup.parser.Parser#setTrackPosition(boolean) 185 @see Attributes#sourceRange(String) 186 @see Node#sourceRange() 187 @see Element#endSourceRange() 188 @since 1.17.1 189 */ 190 public Range.AttributeRange sourceRange() { 191 if (parent == null) return Range.AttributeRange.UntrackedAttr; 192 return parent.sourceRange(key); 193 } 194 195 void html(QuietAppendable accum, Document.OutputSettings out) { 196 html(key, val, accum, out); 197 } 198 199 static void html(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) { 200 key = getValidKey(key, out.syntax()); 201 if (key == null) return; // can't write it :( 202 htmlNoValidate(key, val, accum, out); 203 } 204 205 /** @deprecated internal method; use {@link #html(String, String, QuietAppendable, Document.OutputSettings)} with {@link org.jsoup.internal.QuietAppendable#wrap(Appendable)} instead. Will be removed in jsoup 1.24.1. */ 206 @Deprecated 207 protected void html(Appendable accum, Document.OutputSettings out) throws IOException { 208 html(key, val, accum, out); 209 } 210 211 /** @deprecated internal method; use {@link #html(String, String, QuietAppendable, Document.OutputSettings)} with {@link org.jsoup.internal.QuietAppendable#wrap(Appendable)} instead. Will be removed in jsoup 1.24.1. */ 212 @Deprecated 213 protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException { 214 html(key, val, QuietAppendable.wrap(accum), out); 215 } 216 217 static void htmlNoValidate(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) { 218 // structured like this so that Attributes can check we can write first, so it can add whitespace correctly 219 accum.append(key); 220 if (!shouldCollapseAttribute(key, val, out)) { 221 accum.append("=\""); 222 Entities.escape(accum, Attributes.checkNotNull(val), out, Entities.ForAttribute); // preserves whitespace 223 accum.append('"'); 224 } 225 } 226 227 private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]+"); 228 private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]+"); 229 /** 230 * Get a valid attribute key for the given syntax. If the key is not valid, it will be coerced into a valid key. 231 * @param key the original attribute key 232 * @param syntax HTML or XML 233 * @return the original key if it's valid; a key with invalid characters replaced with "_" otherwise; or null if a valid key could not be created. 234 */ 235 @Nullable public static String getValidKey(String key, Syntax syntax) { 236 if (syntax == Syntax.xml && !isValidXmlKey(key)) { 237 key = xmlKeyReplace.matcher(key).replaceAll("_"); 238 return isValidXmlKey(key) ? key : null; // null if could not be coerced 239 } 240 else if (syntax == Syntax.html && !isValidHtmlKey(key)) { 241 key = htmlKeyReplace.matcher(key).replaceAll("_"); 242 return isValidHtmlKey(key) ? key : null; // null if could not be coerced 243 } 244 return key; 245 } 246 247 // perf critical in html() so using manual scan vs regex: 248 // note that we aren't using anything in supplemental space, so OK to iter charAt 249 private static boolean isValidXmlKey(String key) { 250 // =~ [a-zA-Z_:][-a-zA-Z0-9_:.]* 251 final int length = key.length(); 252 if (length == 0) return false; 253 char c = key.charAt(0); 254 if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == ':')) 255 return false; 256 for (int i = 1; i < length; i++) { 257 c = key.charAt(i); 258 if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':' || c == '.')) 259 return false; 260 } 261 return true; 262 } 263 264 private static boolean isValidHtmlKey(String key) { 265 // =~ [\x00-\x1f\x7f-\x9f "'/=]+ 266 final int length = key.length(); 267 if (length == 0) return false; 268 for (int i = 0; i < length; i++) { 269 char c = key.charAt(i); 270 if ((c <= 0x1f) || (c >= 0x7f && c <= 0x9f) || c == ' ' || c == '"' || c == '\'' || c == '/' || c == '=') 271 return false; 272 } 273 return true; 274 } 275 276 /** 277 Get the string representation of this attribute, implemented as {@link #html()}. 278 @return string 279 */ 280 @Override 281 public String toString() { 282 return html(); 283 } 284 285 /** 286 * Create a new Attribute from an unencoded key and a HTML attribute encoded value. 287 * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars. 288 * @param encodedValue HTML attribute encoded value 289 * @return attribute 290 */ 291 public static Attribute createFromEncoded(String unencodedKey, String encodedValue) { 292 String value = Entities.unescape(encodedValue, true); 293 return new Attribute(unencodedKey, value, null); // parent will get set when Put 294 } 295 296 protected boolean isDataAttribute() { 297 return isDataAttribute(key); 298 } 299 300 protected static boolean isDataAttribute(String key) { 301 return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length(); 302 } 303 304 /** 305 * Collapsible if it's a boolean attribute and value is empty or same as name 306 * 307 * @param out output settings 308 * @return Returns whether collapsible or not 309 * @deprecated internal method; use {@link #shouldCollapseAttribute(String, String, Document.OutputSettings)} instead. Will be removed in jsoup 1.24.1. 310 */ 311 @Deprecated 312 protected final boolean shouldCollapseAttribute(Document.OutputSettings out) { 313 return shouldCollapseAttribute(key, val, out); 314 } 315 316 // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others 317 protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) { 318 return (out.syntax() == Syntax.html && 319 (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key))); 320 } 321 322 /** 323 * Checks if this attribute name is defined as a boolean attribute in HTML5 324 */ 325 public static boolean isBooleanAttribute(final String key) { 326 return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0; 327 } 328 329 @Override 330 public boolean equals(@Nullable Object o) { // note parent not considered 331 if (this == o) return true; 332 if (o == null || getClass() != o.getClass()) return false; 333 Attribute attribute = (Attribute) o; 334 return Objects.equals(key, attribute.key) && Objects.equals(val, attribute.val); 335 } 336 337 @Override 338 public int hashCode() { // note parent not considered 339 return Objects.hash(key, val); 340 } 341 342 @Override 343 public Attribute clone() { 344 try { 345 return (Attribute) super.clone(); 346 } catch (CloneNotSupportedException e) { 347 throw new RuntimeException(e); 348 } 349 } 350}