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