View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.html.impl;
16  
17  import java.io.Serializable;
18  import java.util.ArrayList;
19  import java.util.Collections;
20  import java.util.HashSet;
21  import java.util.Iterator;
22  import java.util.List;
23  import java.util.Objects;
24  
25  import org.apache.commons.lang3.builder.EqualsBuilder;
26  import org.htmlunit.SgmlPage;
27  import org.htmlunit.html.DomDocumentFragment;
28  import org.htmlunit.html.DomNode;
29  import org.htmlunit.html.DomNodeList;
30  import org.htmlunit.html.DomText;
31  import org.w3c.dom.DOMException;
32  import org.w3c.dom.DocumentFragment;
33  import org.w3c.dom.Node;
34  import org.w3c.dom.NodeList;
35  
36  /**
37   * Simple implementation of an Range.
38   *
39   * @author Marc Guillemot
40   * @author Daniel Gredler
41   * @author James Phillpotts
42   * @author Ahmed Ashour
43   * @author Ronald Brill
44   */
45  public class SimpleRange implements Serializable {
46  
47      /** The start (anchor) container. */
48      private DomNode startContainer_;
49  
50      /** The end (focus) container. */
51      private DomNode endContainer_;
52  
53      /**
54       * The start (anchor) offset; units are chars if the start container is a text node or an
55       * input element, DOM nodes otherwise.
56       */
57      private int startOffset_;
58  
59      /**
60       * The end (focus) offset; units are chars if the end container is a text node or an input
61       * element, DOM nodes otherwise.
62       */
63      private int endOffset_;
64  
65      /**
66       * Constructs a range without any content.
67       */
68      public SimpleRange() {
69          // Empty.
70      }
71  
72      /**
73       * Constructs a range for the specified element.
74       *
75       * @param node the node for the range
76       */
77      public SimpleRange(final DomNode node) {
78          startContainer_ = node;
79          endContainer_ = node;
80          startOffset_ = 0;
81          endOffset_ = getMaxOffset(node);
82      }
83  
84      /**
85       * Constructs a range for the provided element and start and end offset.
86       *
87       * @param node the node for the range
88       * @param offset the start and end offset
89       */
90      public SimpleRange(final DomNode node, final int offset) {
91          startContainer_ = node;
92          endContainer_ = node;
93          startOffset_ = offset;
94          endOffset_ = offset;
95      }
96  
97      /**
98       * Constructs a range for the provided elements and offsets.
99       *
100      * @param startNode the start node
101      * @param startOffset the start offset
102      * @param endNode the end node
103      * @param endOffset the end offset
104      */
105     public SimpleRange(final DomNode startNode, final int startOffset, final DomNode endNode, final int endOffset) {
106         startContainer_ = startNode;
107         endContainer_ = endNode;
108         startOffset_ = startOffset;
109         endOffset_ = endOffset;
110         if (startNode == endNode && startOffset > endOffset) {
111             endOffset_ = startOffset;
112         }
113     }
114 
115     /**
116      * Duplicates the contents of this.
117      *
118      * @return DocumentFragment that contains content equivalent to this
119      */
120     public DomDocumentFragment cloneContents() {
121         // Clone the common ancestor.
122         final DomNode ancestor = getCommonAncestorContainer();
123 
124         if (ancestor == null) {
125             return new DomDocumentFragment(null);
126         }
127         final DomNode ancestorClone = ancestor.cloneNode(true);
128 
129         // Find the start container and end container clones.
130         DomNode startClone = null;
131         DomNode endClone = null;
132         final DomNode start = startContainer_;
133         final DomNode end = endContainer_;
134         if (start == ancestor) {
135             startClone = ancestorClone;
136         }
137         if (end == ancestor) {
138             endClone = ancestorClone;
139         }
140         final Iterable<DomNode> descendants = ancestor.getDescendants();
141         if (startClone == null || endClone == null) {
142             final Iterator<DomNode> i = descendants.iterator();
143             final Iterator<DomNode> ci = ancestorClone.getDescendants().iterator();
144             while (i.hasNext()) {
145                 final DomNode e = i.next();
146                 final DomNode ce = ci.next();
147                 if (start == e) {
148                     startClone = ce;
149                 }
150                 else if (end == e) {
151                     endClone = ce;
152                     break;
153                 }
154             }
155         }
156 
157         // Do remove from end first so that it can't affect the offset values
158 
159         // Remove everything following the selection end from the clones.
160         if (endClone == null) {
161             throw new IllegalStateException("Unable to find end node clone.");
162         }
163         deleteAfter(endClone, endOffset_);
164         for (DomNode n = endClone; n != null; n = n.getParentNode()) {
165             while (n.getNextSibling() != null) {
166                 n.getNextSibling().remove();
167             }
168         }
169 
170         // Remove everything prior to the selection start from the clones.
171         if (startClone == null) {
172             throw new IllegalStateException("Unable to find start node clone.");
173         }
174         deleteBefore(startClone, startOffset_);
175         for (DomNode n = startClone; n != null; n = n.getParentNode()) {
176             while (n.getPreviousSibling() != null) {
177                 n.getPreviousSibling().remove();
178             }
179         }
180 
181         final SgmlPage page = ancestor.getPage();
182         final DomDocumentFragment fragment = new DomDocumentFragment(page);
183         if (start == end) {
184             fragment.appendChild(ancestorClone);
185         }
186         else {
187             for (final DomNode n : ancestorClone.getChildNodes()) {
188                 fragment.appendChild(n);
189             }
190         }
191         return fragment;
192     }
193 
194     /**
195      * Produces a new SimpleRange whose boundary-points are equal to the
196      * boundary-points of this.
197      *
198      * @return duplicated simple
199      */
200     public SimpleRange cloneRange() {
201         return new SimpleRange(startContainer_, startOffset_, endContainer_, endOffset_);
202     }
203 
204     /**
205      * Collapse this range onto one of its boundary-points.
206      * @param toStart if true, collapses the Range onto its start; else collapses it onto its end.
207      */
208     public void collapse(final boolean toStart) {
209         if (toStart) {
210             endContainer_ = startContainer_;
211             endOffset_ = startOffset_;
212         }
213         else {
214             startContainer_ = endContainer_;
215             startOffset_ = endOffset_;
216         }
217     }
218 
219     /**
220      * Removes the contents of this range from the containing document or
221      * document fragment without returning a reference to the removed
222      * content.
223      */
224     public void deleteContents() {
225         final DomNode ancestor = getCommonAncestorContainer();
226         if (ancestor != null) {
227             deleteContents(ancestor);
228         }
229     }
230 
231     private void deleteContents(final DomNode ancestor) {
232         final DomNode start;
233         if (isOffsetChars(startContainer_)) {
234             start = startContainer_;
235             String text = getText(start);
236             if (startOffset_ > -1 && startOffset_ < text.length()) {
237                 text = text.substring(0, startOffset_);
238             }
239             setText(start, text);
240         }
241         else if (startContainer_.getChildNodes().getLength() > startOffset_) {
242             start = (DomNode) startContainer_.getChildNodes().item(startOffset_);
243         }
244         else {
245             start = startContainer_.getNextSibling();
246         }
247 
248         final DomNode end;
249         if (isOffsetChars(endContainer_)) {
250             end = endContainer_;
251             String text = getText(end);
252             if (endOffset_ > -1 && endOffset_ < text.length()) {
253                 text = text.substring(endOffset_);
254             }
255             setText(end, text);
256         }
257         else if (endContainer_.getChildNodes().getLength() > endOffset_) {
258             end = (DomNode) endContainer_.getChildNodes().item(endOffset_);
259         }
260         else {
261             end = endContainer_.getNextSibling();
262         }
263 
264         boolean foundStart = false;
265         boolean started = false;
266         final Iterator<DomNode> i = ancestor.getDescendants().iterator();
267         while (i.hasNext()) {
268             final DomNode n = i.next();
269             if (n == end) {
270                 break;
271             }
272             if (n == start) {
273                 foundStart = true;
274             }
275             if (foundStart && (n != start || !isOffsetChars(startContainer_))) {
276                 started = true;
277             }
278             if (started && !n.isAncestorOf(end)) {
279                 i.remove();
280             }
281         }
282     }
283 
284     /**
285      * Moves the contents of a Range from the containing document or document
286      * fragment to a new DocumentFragment.
287      * @return DocumentFragment containing the extracted contents
288      * @throws DOMException in case of error
289      */
290     public DomDocumentFragment extractContents() throws DOMException {
291         final DomDocumentFragment fragment = cloneContents();
292 
293         // Remove everything inside the range from the original nodes.
294         deleteContents();
295 
296         // Build the document fragment using the cloned nodes, and return it.
297         return fragment;
298     }
299 
300     /**
301      * Determines whether this range is collapsed.
302      *
303      * @return true if startContainer equals endContainer and
304      *         startOffset equals endOffset
305      * @throws DOMException in case of error
306      */
307     public boolean isCollapsed() throws DOMException {
308         return startContainer_ == endContainer_ && startOffset_ == endOffset_;
309     }
310 
311     /**
312      * Returns the deepest common ancestor of this range's boundary points.
313      *
314      * @return the deepest common ancestor container of this range's two
315      *         boundary-points.
316      * @throws DOMException in case of error
317      */
318     public DomNode getCommonAncestorContainer() throws DOMException {
319         final HashSet<DomNode> startAncestors = new HashSet<>();
320         DomNode ancestor = startContainer_;
321         while (ancestor != null) {
322             startAncestors.add(ancestor);
323             ancestor = ancestor.getParentNode();
324         }
325 
326         ancestor = endContainer_;
327         while (ancestor != null) {
328             if (startAncestors.contains(ancestor)) {
329                 return ancestor;
330             }
331             ancestor = ancestor.getParentNode();
332         }
333 
334         return null;
335     }
336 
337     /**
338      * Returns the node within which this range ends.
339      *
340      * @return the Node within which this range ends
341      */
342     public DomNode getEndContainer() {
343         return endContainer_;
344     }
345 
346     /**
347      * Returns the offset within the end container.
348      *
349      * @return offset within the ending node of this
350      */
351     public int getEndOffset() {
352         return endOffset_;
353     }
354 
355     /**
356      * Returns the node within which this range begins.
357      *
358      * @return the Node within which this range begins
359      */
360     public DomNode getStartContainer() {
361         return startContainer_;
362     }
363 
364     /**
365      * Returns the offset within the start container.
366      *
367      * @return offset within the starting node of this
368      */
369     public int getStartOffset() {
370         return startOffset_;
371     }
372 
373     /**
374      * Inserts a node into the Document or DocumentFragment at the start of
375      * the Range. If the container is a Text node, this will be split at the
376      * start of the Range (as if the Text node's splitText method was
377      * performed at the insertion point) and the insertion will occur
378      * between the two resulting Text nodes. Adjacent Text nodes will not be
379      * automatically merged. If the node to be inserted is a
380      * DocumentFragment node, the children will be inserted rather than the
381      * DocumentFragment node itself.
382      * @param newNode The node to insert at the start of the Range
383      */
384     public void insertNode(final DomNode newNode) {
385         if (isOffsetChars(startContainer_)) {
386             final DomNode split = startContainer_.cloneNode(false);
387             String text = getText(startContainer_);
388             if (startOffset_ > -1 && startOffset_ < text.length()) {
389                 text = text.substring(0, startOffset_);
390             }
391             setText(startContainer_, text);
392             text = getText(split);
393             if (startOffset_ > -1 && startOffset_ < text.length()) {
394                 text = text.substring(startOffset_);
395             }
396             setText(split, text);
397             insertNodeOrDocFragment(startContainer_.getParentNode(), split, startContainer_.getNextSibling());
398             insertNodeOrDocFragment(startContainer_.getParentNode(), newNode, split);
399         }
400         else {
401             insertNodeOrDocFragment(startContainer_, newNode,
402                     (DomNode) startContainer_.getChildNodes().item(startOffset_));
403         }
404 
405         setStart(newNode, 0);
406     }
407 
408     private static void insertNodeOrDocFragment(final DomNode parent, final DomNode newNode, final DomNode refNode) {
409         if (newNode instanceof DocumentFragment fragment) {
410 
411             final NodeList childNodes = fragment.getChildNodes();
412             while (childNodes.getLength() > 0) {
413                 final Node item = childNodes.item(0);
414                 parent.insertBefore(item, refNode);
415             }
416         }
417         else {
418             parent.insertBefore(newNode, refNode);
419         }
420     }
421 
422     /**
423      * Select a node and its contents.
424      * @param node The node to select.
425      */
426     public void selectNode(final DomNode node) {
427         startContainer_ = node;
428         startOffset_ = 0;
429         endContainer_ = node;
430         endOffset_ = getMaxOffset(node);
431     }
432 
433     /**
434      * Select the contents within a node.
435      * @param node Node to select from
436      */
437     public void selectNodeContents(final DomNode node) {
438         startContainer_ = node;
439         startOffset_ = 0;
440         endContainer_ = node;
441         endOffset_ = getMaxOffset(node);
442     }
443 
444     /**
445      * Sets the attributes describing the end.
446      * @param refNode the refNode
447      * @param offset offset
448      */
449     public void setEnd(final DomNode refNode, final int offset) {
450         endContainer_ = refNode;
451         endOffset_ = offset;
452     }
453 
454     /**
455      * Sets the attributes describing the start.
456      * @param refNode the refNode
457      * @param offset offset
458      */
459     public void setStart(final DomNode refNode, final int offset) {
460         startContainer_ = refNode;
461         startOffset_ = offset;
462     }
463 
464     /**
465      * Reparents the contents of the Range to the given node and inserts the
466      * node at the position of the start of the Range.
467      * @param newParent The node to surround the contents with.
468      */
469     public void surroundContents(final DomNode newParent) {
470         newParent.appendChild(extractContents());
471         insertNode(newParent);
472         setStart(newParent, 0);
473         setEnd(newParent, getMaxOffset(newParent));
474     }
475 
476     /**
477      * {@inheritDoc}
478      */
479     @Override
480     public boolean equals(final Object obj) {
481         if (!(obj instanceof SimpleRange other)) {
482             return false;
483         }
484         return new EqualsBuilder()
485             .append(startContainer_, other.startContainer_)
486             .append(endContainer_, other.endContainer_)
487             .append(startOffset_, other.startOffset_)
488             .append(endOffset_, other.endOffset_).isEquals();
489     }
490 
491     /**
492      * {@inheritDoc}
493      */
494     @Override
495     public int hashCode() {
496         return Objects.hash(startContainer_, endContainer_, startOffset_, endOffset_);
497     }
498 
499     /**
500      * {@inheritDoc}
501      */
502     @Override
503     public String toString() {
504         final DomDocumentFragment fragment = cloneContents();
505         if (fragment.getPage() != null) {
506             return fragment.asNormalizedText();
507         }
508         return "";
509     }
510 
511     private static boolean isOffsetChars(final DomNode node) {
512         return node instanceof DomText || node instanceof SelectableTextInput;
513     }
514 
515     private static String getText(final DomNode node) {
516         if (node instanceof SelectableTextInput input) {
517             return input.getText();
518         }
519         return node.getTextContent();
520     }
521 
522     private static void setText(final DomNode node, final String text) {
523         if (node instanceof SelectableTextInput input) {
524             input.setText(text);
525         }
526         else {
527             node.setTextContent(text);
528         }
529     }
530 
531     private static void deleteBefore(final DomNode node, int offset) {
532         if (isOffsetChars(node)) {
533             String text = getText(node);
534             if (offset > -1 && offset < text.length()) {
535                 text = text.substring(offset);
536             }
537             else {
538                 text = "";
539             }
540             setText(node, text);
541         }
542         else {
543             final DomNodeList<DomNode> children = node.getChildNodes();
544             for (int i = 0; i < offset && i < children.getLength(); i++) {
545                 final DomNode child = children.get(i);
546                 child.remove();
547                 i--;
548                 offset--;
549             }
550         }
551     }
552 
553     private static void deleteAfter(final DomNode node, final int offset) {
554         if (isOffsetChars(node)) {
555             String text = getText(node);
556             if (offset > -1 && offset < text.length()) {
557                 text = text.substring(0, offset);
558                 setText(node, text);
559             }
560         }
561         else {
562             final DomNodeList<DomNode> children = node.getChildNodes();
563             for (int i = offset; i < children.getLength(); i++) {
564                 final DomNode child = children.get(i);
565                 child.remove();
566                 i--;
567             }
568         }
569     }
570 
571     private static int getMaxOffset(final DomNode node) {
572         return isOffsetChars(node) ? getText(node).length() : node.getChildNodes().getLength();
573     }
574 
575     /**
576      * Returns the nodes fully contained within this range in document order.
577      *
578      * @return a list with all nodes contained in this range
579      */
580     public List<DomNode> containedNodes() {
581         final DomNode ancestor = getCommonAncestorContainer();
582         if (ancestor == null) {
583             return Collections.emptyList();
584         }
585 
586         // When start == end (same text node), just return it directly
587         if (startContainer_ == endContainer_ && isOffsetChars(startContainer_)) {
588             return Collections.singletonList(startContainer_);
589         }
590 
591         // Resolve start node without mutating
592         final DomNode start;
593         if (isOffsetChars(startContainer_)) {
594             start = startContainer_;
595         }
596         else if (startContainer_.getChildNodes().getLength() > startOffset_) {
597             start = (DomNode) startContainer_.getChildNodes().item(startOffset_);
598         }
599         else {
600             start = startContainer_.getNextSibling();
601         }
602 
603         // Resolve end node without mutating
604         final DomNode end;
605         if (isOffsetChars(endContainer_)) {
606             end = endContainer_;
607         }
608         else if (endContainer_.getChildNodes().getLength() > endOffset_) {
609             end = (DomNode) endContainer_.getChildNodes().item(endOffset_);
610         }
611         else {
612             end = endContainer_.getNextSibling();
613         }
614 
615         boolean foundStart = false;
616         boolean started = false;
617         final List<DomNode> nodes = new ArrayList<>();
618         for (final DomNode n : ancestor.getDescendants()) {
619             if (n == end) {
620                 break;
621             }
622             if (n == start) {
623                 foundStart = true;
624             }
625             if (foundStart && (n != start || !isOffsetChars(startContainer_))) {
626                 started = true;
627             }
628             if (started && !n.isAncestorOf(end)) {
629                 nodes.add(n);
630             }
631         }
632         return nodes;
633     }
634 }