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 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 if (key == null) return; // can't write it :( 195 htmlNoValidate(key, val, accum, out); 196 } 197 198 /** @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. */ 199 @Deprecated 200 protected void html(Appendable accum, Document.OutputSettings out) throws IOException { 201 html(key, val, accum, out); 202 } 203 204 /** @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. */ 205 @Deprecated 206 protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException { 207 html(key, val, QuietAppendable.wrap(accum), out); 208 } 209 210 static void htmlNoValidate(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) { 211 // structured like this so that Attributes can check we can write first, so it can add whitespace correctly 212 accum.append(key); 213 if (!shouldCollapseAttribute(key, val, out)) { 214 accum.append("=\""); 215 Entities.escape(accum, Attributes.checkNotNull(val), out, Entities.ForAttribute); // preserves whitespace 216 accum.append('"'); 217 } 218 } 219 220 private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]+"); 221 private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]+"); 222 /** 223 * Get a valid attribute key for the given syntax. If the key is not valid, it will be coerced into a valid key. 224 * @param key the original attribute key 225 * @param syntax HTML or XML 226 * @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. 227 */ 228 @Nullable public static String getValidKey(String key, Syntax syntax) { 229 if (syntax == Syntax.xml && !isValidXmlKey(key)) { 230 key = xmlKeyReplace.matcher(key).replaceAll("_"); 231 return isValidXmlKey(key) ? key : null; // null if could not be coerced 232 } 233 else if (syntax == Syntax.html && !isValidHtmlKey(key)) { 234 key = htmlKeyReplace.matcher(key).replaceAll("_"); 235 return isValidHtmlKey(key) ? key : null; // null if could not be coerced 236 } 237 return key; 238 } 239 240 // perf critical in html() so using manual scan vs regex: 241 // note that we aren't using anything in supplemental space, so OK to iter charAt 242 private static boolean isValidXmlKey(String key) { 243 // =~ [a-zA-Z_:][-a-zA-Z0-9_:.]* 244 final int length = key.length(); 245 if (length == 0) return false; 246 char c = key.charAt(0); 247 if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == ':')) 248 return false; 249 for (int i = 1; i < length; i++) { 250 c = key.charAt(i); 251 if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':' || c == '.')) 252 return false; 253 } 254 return true; 255 } 256 257 private static boolean isValidHtmlKey(String key) { 258 // =~ [\x00-\x1f\x7f-\x9f "'/=]+ 259 final int length = key.length(); 260 if (length == 0) return false; 261 for (int i = 0; i < length; i++) { 262 char c = key.charAt(i); 263 if ((c <= 0x1f) || (c >= 0x7f && c <= 0x9f) || c == ' ' || c == '"' || c == '\'' || c == '/' || c == '=') 264 return false; 265 } 266 return true; 267 } 268 269 /** 270 Get the string representation of this attribute, implemented as {@link #html()}. 271 @return string 272 */ 273 @Override 274 public String toString() { 275 return html(); 276 } 277 278 /** 279 * Create a new Attribute from an unencoded key and a HTML attribute encoded value. 280 * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars. 281 * @param encodedValue HTML attribute encoded value 282 * @return attribute 283 */ 284 public static Attribute createFromEncoded(String unencodedKey, String encodedValue) { 285 String value = Entities.unescape(encodedValue, true); 286 return new Attribute(unencodedKey, value, null); // parent will get set when Put 287 } 288 289 protected boolean isDataAttribute() { 290 return isDataAttribute(key); 291 } 292 293 protected static boolean isDataAttribute(String key) { 294 return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length(); 295 } 296 297 /** 298 * Collapsible if it's a boolean attribute and value is empty or same as name 299 * 300 * @param out output settings 301 * @return Returns whether collapsible or not 302 * @deprecated internal method; use {@link #shouldCollapseAttribute(String, String, Document.OutputSettings)} instead. Will be removed in jsoup 1.24.1. 303 */ 304 @Deprecated 305 protected final boolean shouldCollapseAttribute(Document.OutputSettings out) { 306 return shouldCollapseAttribute(key, val, out); 307 } 308 309 // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others 310 protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) { 311 return (out.syntax() == Syntax.html && 312 (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key))); 313 } 314 315 /** 316 * Checks if this attribute name is defined as a boolean attribute in HTML5 317 */ 318 public static boolean isBooleanAttribute(final String key) { 319 return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0; 320 } 321 322 @Override 323 public boolean equals(@Nullable Object o) { // note parent not considered 324 if (this == o) return true; 325 if (o == null || getClass() != o.getClass()) return false; 326 Attribute attribute = (Attribute) o; 327 return Objects.equals(key, attribute.key) && Objects.equals(val, attribute.val); 328 } 329 330 @Override 331 public int hashCode() { // note parent not considered 332 return Objects.hash(key, val); 333 } 334 335 @Override 336 public Attribute clone() { 337 try { 338 return (Attribute) super.clone(); 339 } catch (CloneNotSupportedException e) { 340 throw new RuntimeException(e); 341 } 342 } 343}