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