xref: /aosp_15_r20/external/jsoup/src/main/java/org/jsoup/nodes/Attribute.java (revision 6da8f8c4bc310ad659121b84dd089062417a2ce2)
1 package org.jsoup.nodes;
2 
3 import org.jsoup.SerializationException;
4 import org.jsoup.helper.Validate;
5 import org.jsoup.internal.Normalizer;
6 import org.jsoup.internal.StringUtil;
7 import org.jsoup.nodes.Document.OutputSettings.Syntax;
8 import org.jspecify.annotations.Nullable;
9 
10 import java.io.IOException;
11 import java.util.Arrays;
12 import java.util.Map;
13 import java.util.regex.Pattern;
14 
15 /**
16  A single key + value attribute. (Only used for presentation.)
17  */
18 public class Attribute implements Map.Entry<String, String>, Cloneable  {
19     private static final String[] booleanAttributes = {
20             "allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled",
21             "formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize",
22             "noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected",
23             "sortable", "truespeed", "typemustmatch"
24     };
25 
26     private String key;
27     @Nullable private String val;
28     @Nullable Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface
29 
30     /**
31      * Create a new attribute from unencoded (raw) key and value.
32      * @param key attribute key; case is preserved.
33      * @param value attribute value (may be null)
34      * @see #createFromEncoded
35      */
Attribute(String key, @Nullable String value)36     public Attribute(String key, @Nullable String value) {
37         this(key, value, null);
38     }
39 
40     /**
41      * Create a new attribute from unencoded (raw) key and value.
42      * @param key attribute key; case is preserved.
43      * @param val attribute value (may be null)
44      * @param parent the containing Attributes (this Attribute is not automatically added to said Attributes)
45      * @see #createFromEncoded*/
Attribute(String key, @Nullable String val, @Nullable Attributes parent)46     public Attribute(String key, @Nullable String val, @Nullable Attributes parent) {
47         Validate.notNull(key);
48         key = key.trim();
49         Validate.notEmpty(key); // trimming could potentially make empty, so validate here
50         this.key = key;
51         this.val = val;
52         this.parent = parent;
53     }
54 
55     /**
56      Get the attribute key.
57      @return the attribute key
58      */
59     @Override
getKey()60     public String getKey() {
61         return key;
62     }
63 
64     /**
65      Set the attribute key; case is preserved.
66      @param key the new key; must not be null
67      */
setKey(String key)68     public void setKey(String key) {
69         Validate.notNull(key);
70         key = key.trim();
71         Validate.notEmpty(key); // trimming could potentially make empty, so validate here
72         if (parent != null) {
73             int i = parent.indexOfKey(this.key);
74             if (i != Attributes.NotFound) {
75                 String oldKey = parent.keys[i];
76                 parent.keys[i] = key;
77 
78                 // if tracking source positions, update the key in the range map
79                 Map<String, Range.AttributeRange> ranges = parent.getRanges();
80                 if (ranges != null) {
81                     Range.AttributeRange range = ranges.remove(oldKey);
82                     ranges.put(key, range);
83                 }
84             }
85         }
86         this.key = key;
87     }
88 
89     /**
90      Get the attribute value. Will return an empty string if the value is not set.
91      @return the attribute value
92      */
93     @Override
getValue()94     public String getValue() {
95         return Attributes.checkNotNull(val);
96     }
97 
98     /**
99      * 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      */
hasDeclaredValue()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      */
setValue(@ullable String val)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      */
html()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      */
sourceRange()151     public Range.AttributeRange sourceRange() {
152         if (parent == null) return Range.AttributeRange.UntrackedAttr;
153         return parent.sourceRange(key);
154     }
155 
html(Appendable accum, Document.OutputSettings out)156     protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
157         html(key, val, accum, out);
158     }
159 
html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out)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 
htmlNoValidate(String key, @Nullable String val, Appendable accum, Document.OutputSettings out)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 
getValidKey(String key, Syntax syntax)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
toString()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      */
createFromEncoded(String unencodedKey, String encodedValue)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 
isDataAttribute()214     protected boolean isDataAttribute() {
215         return isDataAttribute(key);
216     }
217 
isDataAttribute(String key)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      */
shouldCollapseAttribute(Document.OutputSettings out)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
shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out)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      */
isBooleanAttribute(final String key)242     public static boolean isBooleanAttribute(final String key) {
243         return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0;
244     }
245 
246     @Override
equals(@ullable Object o)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
hashCode()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
clone()263     public Attribute clone() {
264         try {
265             return (Attribute) super.clone();
266         } catch (CloneNotSupportedException e) {
267             throw new RuntimeException(e);
268         }
269     }
270 }
271