001package org.jsoup.nodes; 002 003import org.jsoup.helper.Validate; 004import org.jsoup.internal.QuietAppendable; 005import org.jsoup.internal.SharedConstants; 006import org.jsoup.internal.StringUtil; 007import org.jsoup.nodes.Document.OutputSettings.Syntax; 008import org.jsoup.parser.ParseSettings; 009import org.jspecify.annotations.Nullable; 010 011import java.util.AbstractMap; 012import java.util.AbstractSet; 013import java.util.ArrayList; 014import java.util.Arrays; 015import java.util.Collections; 016import java.util.ConcurrentModificationException; 017import java.util.HashMap; 018import java.util.HashSet; 019import java.util.Iterator; 020import java.util.List; 021import java.util.Map; 022import java.util.NoSuchElementException; 023import java.util.Objects; 024import java.util.Set; 025 026import static org.jsoup.internal.Normalizer.asciiLowerCase; 027import static org.jsoup.internal.Normalizer.equalsIgnoreAsciiCase; 028import static org.jsoup.nodes.Document.OutputSettings.Syntax.xml; 029import static org.jsoup.nodes.Range.AttributeRange.UntrackedAttr; 030 031/** 032 * The attributes of an Element. 033 * <p> 034 * During parsing, attributes in with the same name in an element are deduplicated, according to the configured parser's 035 * attribute case-sensitive setting. It is possible to have duplicate attributes subsequently if 036 * {@link #add(String, String)} vs {@link #put(String, String)} is used. 037 * </p> 038 * <p> 039 * Attribute name and value comparisons are generally <b>case-sensitive</b>. By default for HTML, attribute names are 040 * normalized to lower-case on parsing. That means you should use lower-case strings when referring to attributes by 041 * name. Case-insensitive name comparisons fold only ASCII letters; non-ASCII characters are compared as written. 042 * </p> 043 * 044 * @author Jonathan Hedley, jonathan@hedley.net 045 */ 046public class Attributes implements Iterable<Attribute>, Cloneable { 047 // The Attributes object is only created on the first use of an attribute; the Element will just have a null 048 // Attribute slot otherwise 049 050 static final char InternalPrefix = '/'; // Indicates an internal key. Can't be set via HTML. (It could be set via accessor, but not too worried about that. Suppressed from list, iter, size.) 051 protected static final String dataPrefix = "data-"; // data attributes 052 private static final String EmptyString = ""; 053 054 // manages the key/val arrays 055 private static final int InitialCapacity = 3; // sampling found mean count when attrs present = 1.49; 1.08 overall. 2.6:1 don't have any attrs. 056 private static final int GrowthFactor = 2; 057 static final int NotFound = -1; 058 059 // the number of instance fields is kept as low as possible giving an object size of 24 bytes 060 int size = 0; // number of slots used (not total capacity, which is keys.length). Package visible for actual size (incl internal) 061 @Nullable String[] keys = new String[InitialCapacity]; // keys is not null, but contents may be. Same for vals 062 @Nullable Object[] vals = new Object[InitialCapacity]; // Genericish: all non-internal attribute values must be Strings and are cast on access. 063 // todo - make keys iterable without creating Attribute objects 064 065 // check there's room for more 066 private void checkCapacity(int minNewSize) { 067 Validate.isTrue(minNewSize >= size); 068 int curCap = keys.length; 069 if (curCap >= minNewSize) 070 return; 071 int newCap = curCap >= InitialCapacity ? size * GrowthFactor : InitialCapacity; 072 if (minNewSize > newCap) 073 newCap = minNewSize; 074 075 keys = Arrays.copyOf(keys, newCap); 076 vals = Arrays.copyOf(vals, newCap); 077 } 078 079 int indexOfKey(String key) { 080 Validate.notNull(key); 081 for (int i = 0; i < size; i++) { 082 if (key.equals(keys[i])) 083 return i; 084 } 085 return NotFound; 086 } 087 088 /** 089 Finds a visible attribute's range index, skipping internal metadata slots. 090 */ 091 int visibleIndexOfKey(String key) { 092 Validate.notNull(key); 093 int visible = 0; 094 for (int i = 0; i < size; i++) { 095 String attrKey = keys[i]; 096 if (isInternalKey(attrKey)) 097 continue; 098 if (key.equals(attrKey)) 099 return visible; 100 visible++; 101 } 102 return NotFound; 103 } 104 105 /** 106 Maps an attribute array slot to the matching visible attribute index. 107 */ 108 private int visibleIndex(int index) { 109 int visible = 0; 110 for (int i = 0; i < index; i++) { 111 if (!isInternalKey(keys[i])) 112 visible++; 113 } 114 return visible; 115 } 116 117 private int indexOfKeyIgnoreCase(String key) { 118 Validate.notNull(key); 119 for (int i = 0; i < size; i++) { 120 if (equalsIgnoreAsciiCase(key, keys[i])) 121 return i; 122 } 123 return NotFound; 124 } 125 126 /** 127 Boolean attributes have null values, and internal attributes may hold arbitrary objects; return empty for either. 128 */ 129 static String checkNotNull(@Nullable Object val) { 130 return val instanceof String ? (String) val : EmptyString; 131 } 132 133 /** 134 Get an attribute value by key. 135 @param key the (case-sensitive) attribute key 136 @return the attribute value if set; or empty string if not set (or a boolean attribute). 137 @see #hasKey(String) 138 */ 139 public String get(String key) { 140 int i = indexOfKey(key); 141 return i == NotFound ? EmptyString : checkNotNull(vals[i]); 142 } 143 144 /** 145 Get an Attribute by key. The Attribute will remain connected to these Attributes, so changes made via 146 {@link Attribute#setKey(String)}, {@link Attribute#setValue(String)} etc will cascade back to these Attributes and 147 their owning Element. 148 @param key the (case-sensitive) attribute key 149 @return the Attribute for this key, or null if not present. 150 @since 1.17.2 151 */ 152 @Nullable public Attribute attribute(String key) { 153 int i = indexOfKey(key); 154 return i == NotFound ? null : new Attribute(key, checkNotNull(vals[i]), this); 155 } 156 157 /** 158 * Get an attribute's value by case-insensitive key 159 * @param key the attribute name 160 * @return the first matching attribute value if set; or empty string if not set (ora boolean attribute). 161 */ 162 public String getIgnoreCase(String key) { 163 int i = indexOfKeyIgnoreCase(key); 164 return i == NotFound ? EmptyString : checkNotNull(vals[i]); 165 } 166 167 /** 168 * Adds a new attribute. Will produce duplicates if the key already exists. 169 * @see Attributes#put(String, String) 170 */ 171 public Attributes add(String key, @Nullable String value) { 172 addObject(key, value); 173 return this; 174 } 175 176 private void addObject(String key, @Nullable Object value) { 177 checkCapacity(size + 1); 178 keys[size] = key; 179 vals[size] = value; 180 size++; 181 } 182 183 /** 184 * Set a new attribute, or replace an existing one by key. 185 * @param key case sensitive attribute key (not null) 186 * @param value attribute value (which can be null, to set a true boolean attribute) 187 * @return these attributes, for chaining 188 */ 189 public Attributes put(String key, @Nullable String value) { 190 Validate.notNull(key); 191 int i = indexOfKey(key); 192 if (i != NotFound) 193 vals[i] = value; 194 else 195 addObject(key, value); 196 return this; 197 } 198 199 /** 200 Get the map holding any user-data associated with these Attributes. Will be created empty on first use. Held as 201 an internal attribute, not a field member, to reduce the memory footprint of Attributes when not used. Can hold 202 arbitrary objects; use for connecting W3C nodes to Elements, etc. 203 * @return the map holding user-data 204 */ 205 @SuppressWarnings("unchecked") 206 Map<String, Object> userData() { 207 final Map<String, Object> userData; 208 int i = indexOfKey(SharedConstants.UserDataKey); 209 if (i == NotFound) { 210 userData = new HashMap<>(); 211 addObject(SharedConstants.UserDataKey, userData); 212 } else { 213 userData = (Map<String, Object>) vals[i]; 214 } 215 assert userData != null; 216 return userData; 217 } 218 219 /** 220 Check if these attributes have any user data associated with them. 221 */ 222 boolean hasUserData() { 223 return hasKey(SharedConstants.UserDataKey); 224 } 225 226 /** 227 Get an arbitrary user-data object by key. 228 * @param key case-sensitive key to the object. 229 * @return the object associated to this key, or {@code null} if not found. 230 * @see #userData(String key, Object val) 231 * @since 1.17.1 232 */ 233 @Nullable 234 public Object userData(String key) { 235 Validate.notNull(key); 236 if (!hasUserData()) return null; // no user data exists 237 Map<String, Object> userData = userData(); 238 return userData.get(key); 239 } 240 241 /** 242 Set an arbitrary user-data object by key. Will be treated as an internal attribute, so will not be emitted in HTML. 243 * @param key case-sensitive key 244 * @param value object value. Providing a {@code null} value has the effect of removing the key from the userData map. 245 * @return these attributes 246 * @see #userData(String key) 247 * @since 1.17.1 248 */ 249 public Attributes userData(String key, @Nullable Object value) { 250 Validate.notNull(key); 251 if (value == null && !hasKey(SharedConstants.UserDataKey)) return this; // no user data exists, so short-circuit 252 Map<String, Object> userData = userData(); 253 if (value == null) userData.remove(key); 254 else userData.put(key, value); 255 return this; 256 } 257 258 /** 259 Gets the range spans, if source tracking was used. 260 */ 261 Range.@Nullable Spans spans() { 262 int i = indexOfKey(SharedConstants.RangeSpansKey); 263 return i == NotFound ? null : (Range.Spans) vals[i]; 264 } 265 266 /** 267 Gets or creates the range spans for this attributes object. 268 */ 269 Range.Spans ensureSpans() { 270 Range.Spans rangeSpans = spans(); 271 if (rangeSpans == null) { 272 rangeSpans = new Range.Spans(); 273 addObject(SharedConstants.RangeSpansKey, rangeSpans); 274 } 275 return rangeSpans; 276 } 277 278 /** Sets the leaf node's source ranges without discarding existing attribute ranges. */ 279 void putSpans(Range.Spans rangeSpans) { 280 int i = indexOfKey(SharedConstants.RangeSpansKey); 281 if (i == NotFound) { 282 addObject(SharedConstants.RangeSpansKey, rangeSpans); 283 } else { 284 Range.Spans attributeSpans = (Range.Spans) vals[i]; 285 rangeSpans.copyAttributeRanges(attributeSpans); 286 vals[i] = rangeSpans; 287 } 288 } 289 290 void putIgnoreCase(String key, @Nullable String value) { 291 int i = indexOfKeyIgnoreCase(key); 292 if (i != NotFound) { 293 vals[i] = value; 294 String old = keys[i]; 295 assert old != null; 296 if (!old.equals(key)) // case changed, update 297 keys[i] = key; 298 } 299 else 300 addObject(key, value); 301 } 302 303 /** 304 * Set a new boolean attribute. Removes the attribute if the value is false. 305 * @param key case <b>insensitive</b> attribute key 306 * @param value attribute value 307 * @return these attributes, for chaining 308 */ 309 public Attributes put(String key, boolean value) { 310 if (value) 311 putIgnoreCase(key, null); 312 else 313 remove(key); 314 return this; 315 } 316 317 /** 318 Set a new attribute, or replace an existing one by key. 319 @param attribute attribute with case-sensitive key 320 @return these attributes, for chaining 321 */ 322 public Attributes put(Attribute attribute) { 323 Validate.notNull(attribute); 324 put(attribute.getKey(), attribute.getValue()); 325 attribute.parent = this; 326 return this; 327 } 328 329 // removes and shifts up 330 @SuppressWarnings("AssignmentToNull") 331 private void remove(int index) { 332 Validate.isFalse(index >= size); 333 Range.Spans rangeSpans = spans(); 334 // Source ranges are stored by visible attribute index; internal metadata slots have no matching range record. 335 if (rangeSpans != null && !isInternalKey(keys[index])) 336 rangeSpans.removeAttributeRange(visibleIndex(index)); 337 338 int shifted = size - index - 1; 339 if (shifted > 0) { 340 System.arraycopy(keys, index + 1, keys, index, shifted); 341 System.arraycopy(vals, index + 1, vals, index, shifted); 342 } 343 size--; 344 keys[size] = null; // release hold 345 vals[size] = null; 346 } 347 348 /** 349 Remove an attribute by key. <b>Case sensitive.</b> 350 @param key attribute key to remove 351 */ 352 public void remove(String key) { 353 int i = indexOfKey(key); 354 if (i != NotFound) 355 remove(i); 356 } 357 358 /** 359 Remove an attribute by key. <b>Case insensitive.</b> 360 @param key attribute key to remove 361 */ 362 public void removeIgnoreCase(String key) { 363 int i = indexOfKeyIgnoreCase(key); 364 if (i != NotFound) 365 remove(i); 366 } 367 368 /** 369 Tests if these attributes contain an attribute with this key. 370 @param key case-sensitive key to check for 371 @return true if key exists, false otherwise 372 */ 373 public boolean hasKey(String key) { 374 return indexOfKey(key) != NotFound; 375 } 376 377 /** 378 Tests if these attributes contain an attribute with this key. 379 @param key key to check for 380 @return true if key exists, false otherwise 381 */ 382 public boolean hasKeyIgnoreCase(String key) { 383 return indexOfKeyIgnoreCase(key) != NotFound; 384 } 385 386 /** 387 * Check if these attributes contain an attribute with a value for this key. 388 * @param key key to check for 389 * @return true if key exists, and it has a value 390 */ 391 public boolean hasDeclaredValueForKey(String key) { 392 int i = indexOfKey(key); 393 return i != NotFound && vals[i] != null; 394 } 395 396 /** 397 * Check if these attributes contain an attribute with a value for this key. 398 * @param key case-insensitive key to check for 399 * @return true if key exists, and it has a value 400 */ 401 public boolean hasDeclaredValueForKeyIgnoreCase(String key) { 402 int i = indexOfKeyIgnoreCase(key); 403 return i != NotFound && vals[i] != null; 404 } 405 406 /** 407 Get the number of attributes in this set, excluding any internal-only attributes (e.g. user data). 408 <p>Internal attributes are excluded from the {@link #html()}, {@link #asList()}, and {@link #iterator()} 409 methods.</p> 410 411 @return size 412 */ 413 public int size() { 414 if (size == 0) return 0; 415 int count = 0; 416 for (int i = 0; i < size; i++) { 417 if (!isInternalKey(keys[i])) count++; 418 } 419 return count; 420 } 421 422 /** 423 Test if this Attributes list is empty. 424 <p>This does not include internal attributes, such as user data.</p> 425 */ 426 public boolean isEmpty() { 427 return size() == 0; 428 } 429 430 /** 431 Add all the attributes from the incoming set to this set. 432 @param incoming attributes to add to these attributes. 433 */ 434 public void addAll(Attributes incoming) { 435 int incomingSize = incoming.size(); // not adding internal 436 if (incomingSize == 0) return; 437 checkCapacity(size + incomingSize); 438 439 boolean needsPut = size != 0; // if this set is empty, no need to check existing set, so can add() vs put() 440 // (and save bashing on the indexOfKey() 441 for (Attribute attr : incoming) { 442 if (needsPut) 443 put(attr); 444 else 445 addObject(attr.getKey(), attr.getValue()); 446 } 447 } 448 449 /** 450 Get the source ranges (start to end position) in the original input source from which this attribute's <b>name</b> 451 and <b>value</b> were parsed. 452 <p>Position tracking must be enabled before parsing the content.</p> 453 @param key the attribute name 454 @return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range 455 was not tracked. 456 @see org.jsoup.parser.Parser#setTrackPosition(boolean) 457 @see Attribute#sourceRange() 458 @see Node#sourceRange() 459 @see Element#endSourceRange() 460 @since 1.17.1 461 */ 462 public Range.AttributeRange sourceRange(String key) { 463 int index = visibleIndexOfKey(key); 464 if (index == NotFound) return UntrackedAttr; 465 Range.Spans rangeSpans = spans(); 466 return rangeSpans != null ? rangeSpans.attributeRange(index) : UntrackedAttr; 467 } 468 469 @Override 470 public Iterator<Attribute> iterator() { 471 //noinspection ReturnOfInnerClass 472 return new Iterator<Attribute>() { 473 int expectedSize = size; 474 int i = 0; 475 476 @Override 477 public boolean hasNext() { 478 checkModified(); 479 while (i < size) { 480 String key = keys[i]; 481 assert key != null; 482 if (isInternalKey(key)) // skip over internal keys 483 i++; 484 else 485 break; 486 } 487 488 return i < size; 489 } 490 491 @Override 492 public Attribute next() { 493 checkModified(); 494 if (i >= size) throw new NoSuchElementException(); 495 String key = keys[i]; 496 assert key != null; 497 final Attribute attr = new Attribute(key, (String) vals[i], Attributes.this); 498 i++; 499 return attr; 500 } 501 502 private void checkModified() { 503 if (size != expectedSize) throw new ConcurrentModificationException("Use Iterator#remove() instead to remove attributes while iterating."); 504 } 505 506 @Override 507 public void remove() { 508 Attributes.this.remove(--i); // next() advanced, so rewind 509 expectedSize--; 510 } 511 }; 512 } 513 514 /** 515 Get the attributes as a List, for iteration. 516 @return a view of the attributes as an unmodifiable List. 517 */ 518 public List<Attribute> asList() { 519 ArrayList<Attribute> list = new ArrayList<>(size); 520 for (int i = 0; i < size; i++) { 521 String key = keys[i]; 522 assert key != null; 523 if (isInternalKey(key)) 524 continue; // skip internal keys 525 Attribute attr = new Attribute(key, (String) vals[i], Attributes.this); 526 list.add(attr); 527 } 528 return Collections.unmodifiableList(list); 529 } 530 531 /** 532 * Retrieves a filtered view of attributes that are HTML5 custom data attributes; that is, attributes with keys 533 * starting with {@code data-}. 534 * @return map of custom data attributes. 535 */ 536 public Map<String, String> dataset() { 537 return new Dataset(this); 538 } 539 540 /** 541 Get the HTML representation of these attributes. 542 @return HTML 543 */ 544 public String html() { 545 StringBuilder sb = StringUtil.borrowBuilder(); 546 html(QuietAppendable.wrap(sb), new Document.OutputSettings()); // output settings a bit funky, but this html() seldom used 547 return StringUtil.releaseBuilder(sb); 548 } 549 550 final void html(final QuietAppendable accum, final Document.OutputSettings out) { 551 final int sz = size; 552 final Syntax syntax = out.syntax(); 553 @Nullable Set<String> usedKeys = null; // avoid allocation for normal serialization 554 for (int i = 0; i < sz; i++) { 555 String key = keys[i]; 556 assert key != null; 557 if (isInternalKey(key)) 558 continue; 559 String validated = Attribute.getValidKey(key, syntax); 560 if (!validated.equals(key)) { 561 if (usedKeys == null) 562 usedKeys = collectSourceKeys(syntax); // valid source names win regardless of attribute order 563 // preserve repaired attributes without outputting duplicate names 564 while (!usedKeys.add(comparisonKey(validated, syntax))) 565 validated = StringUtil.concat('_', validated); 566 } 567 Attribute.htmlNoValidate(validated, (String) vals[i], accum.append(' '), out); 568 } 569 } 570 571 /** Collects source keys so repaired names can be made unique without changing valid names. */ 572 private Set<String> collectSourceKeys(Syntax syntax) { 573 Set<String> sourceKeys = new HashSet<>(size); 574 for (int i = 0; i < size; i++) { 575 String key = keys[i]; 576 assert key != null; 577 if (!isInternalKey(key)) 578 sourceKeys.add(comparisonKey(key, syntax)); 579 } 580 return sourceKeys; 581 } 582 583 /** Normalizes a key for the output syntax's case sensitivity. */ 584 private static String comparisonKey(String key, Syntax syntax) { 585 return syntax == xml ? key : asciiLowerCase(key); 586 } 587 588 /** Compares keys with the requested case sensitivity. */ 589 private static boolean keysEqual(String first, String second, boolean caseSensitive) { 590 return caseSensitive ? first.equals(second) : equalsIgnoreAsciiCase(first, second); 591 } 592 593 @Override 594 public String toString() { 595 return html(); 596 } 597 598 /** 599 Checks if these attributes have the same names and values as another set, ignoring attribute order. Internal 600 attributes, such as source ranges and user data, are excluded. 601 602 @param o attributes to compare with 603 @return if both sets of attributes have the same content 604 */ 605 @Override 606 public boolean equals(@Nullable Object o) { 607 if (this == o) return true; 608 if (o == null || getClass() != o.getClass()) return false; 609 610 Attributes that = (Attributes) o; 611 if (size() != that.size()) return false; 612 for (int i = 0; i < size; i++) { 613 String key = keys[i]; 614 assert key != null; 615 if (isInternalKey(key)) continue; 616 617 int here = 0, there = 0; 618 for (int j = 0; j < size; j++) { 619 if (key.equals(keys[j]) && Objects.equals(vals[i], vals[j])) here++; 620 } 621 for (int j = 0; j < that.size; j++) { 622 if (key.equals(that.keys[j]) && Objects.equals(vals[i], that.vals[j])) there++; 623 } 624 if (here != there) return false; 625 } 626 return true; 627 } 628 629 /** 630 Calculates an order-independent hash code from attribute names and values, excluding internal attributes. 631 632 @return calculated hashcode 633 */ 634 @Override 635 public int hashCode() { 636 int result = 0; 637 for (int i = 0; i < size; i++) { 638 String key = keys[i]; 639 assert key != null; 640 if (!isInternalKey(key)) 641 result += 31 * key.hashCode() + Objects.hashCode(vals[i]); 642 } 643 return result; 644 } 645 646 @Override 647 @SuppressWarnings("unchecked") 648 public Attributes clone() { 649 Attributes clone; 650 try { 651 clone = (Attributes) super.clone(); 652 } catch (CloneNotSupportedException e) { 653 throw new RuntimeException(e); 654 } 655 clone.size = size; 656 clone.keys = Arrays.copyOf(keys, size); 657 clone.vals = Arrays.copyOf(vals, size); 658 659 // make a copy of the user data map. (Contents are shallow). 660 int i = indexOfKey(SharedConstants.UserDataKey); 661 if (i != NotFound) { 662 clone.vals[i] = new HashMap<>((Map<String, Object>) vals[i]); 663 } 664 665 // make a copy of the range spans, if present. 666 i = indexOfKey(SharedConstants.RangeSpansKey); 667 if (i != NotFound) { 668 clone.vals[i] = ((Range.Spans) vals[i]).copy(); 669 } 670 671 return clone; 672 } 673 674 /** 675 * Internal method. Lowercases all (non-internal) keys. 676 */ 677 public void normalize() { 678 for (int i = 0; i < size; i++) { 679 assert keys[i] != null; 680 String key = keys[i]; 681 assert key != null; 682 if (!isInternalKey(key)) 683 keys[i] = asciiLowerCase(key); 684 } 685 } 686 687 /** 688 * Internal method. Removes duplicate attribute by name. Settings for case sensitivity of key names. 689 * @param settings case sensitivity 690 * @return number of removed dupes 691 */ 692 public int deduplicate(ParseSettings settings) { 693 if (size == 0) return 0; 694 boolean preserve = settings.preserveAttributeCase(); 695 int dupes = 0; 696 for (int i = 0; i < size; i++) { 697 String keyI = keys[i]; 698 assert keyI != null; 699 for (int j = i + 1; j < size; j++) { 700 if (keysEqual(keyI, keys[j], preserve)) { 701 dupes++; 702 remove(j); 703 j--; 704 } 705 } 706 } 707 return dupes; 708 } 709 710 private static class Dataset extends AbstractMap<String, String> { 711 private final Attributes attributes; 712 713 private Dataset(Attributes attributes) { 714 this.attributes = attributes; 715 } 716 717 @Override 718 public Set<Entry<String, String>> entrySet() { 719 return new EntrySet(); 720 } 721 722 @Override 723 public String put(String key, String value) { 724 String dataKey = dataKey(key); 725 String oldValue = attributes.hasKey(dataKey) ? attributes.get(dataKey) : null; 726 attributes.put(dataKey, value); 727 return oldValue; 728 } 729 730 private class EntrySet extends AbstractSet<Map.Entry<String, String>> { 731 732 @Override 733 public Iterator<Map.Entry<String, String>> iterator() { 734 return new DatasetIterator(); 735 } 736 737 @Override 738 public int size() { 739 int count = 0; 740 Iterator<Entry<String, String>> iter = new DatasetIterator(); 741 while (iter.hasNext()) 742 count++; 743 return count; 744 } 745 } 746 747 private class DatasetIterator implements Iterator<Map.Entry<String, String>> { 748 private final Iterator<Attribute> attrIter = attributes.iterator(); 749 private Attribute attr; 750 @Override public boolean hasNext() { 751 while (attrIter.hasNext()) { 752 attr = attrIter.next(); 753 if (attr.isDataAttribute()) return true; 754 } 755 return false; 756 } 757 758 @Override public Entry<String, String> next() { 759 return new Attribute(attr.getKey().substring(dataPrefix.length()), attr.getValue()); 760 } 761 762 @Override public void remove() { 763 attributes.remove(attr.getKey()); 764 } 765 } 766 } 767 768 private static String dataKey(String key) { 769 return dataPrefix + key; 770 } 771 772 static String internalKey(String key) { 773 return InternalPrefix + key; 774 } 775 776 static boolean isInternalKey(String key) { 777 return key.length() > 1 && key.charAt(0) == InternalPrefix; 778 } 779}