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