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.Iterator;
21  import java.util.List;
22  import java.util.Objects;
23  
24  import org.apache.commons.lang3.builder.EqualsBuilder;
25  import org.htmlunit.SgmlPage;
26  import org.htmlunit.html.DomDocumentFragment;
27  import org.htmlunit.html.DomNode;
28  import org.htmlunit.html.DomNodeList;
29  import org.htmlunit.html.DomText;
30  import org.w3c.dom.DOMException;
31  import org.w3c.dom.DocumentFragment;
32  import org.w3c.dom.Node;
33  import org.w3c.dom.NodeList;
34  
35  /**
36   * Simple implementation of a DOM Range.
37   *
38   * @author Marc Guillemot
39   * @author Daniel Gredler
40   * @author James Phillpotts
41   * @author Ahmed Ashour
42   * @author Ronald Brill
43   */
44  public class SimpleRange implements Serializable {
45  
46      /** The start (anchor) container. */
47      private DomNode startContainer_;
48  
49      /** The end (focus) container. */
50      private DomNode endContainer_;
51  
52      /**
53       * The start (anchor) offset; units are chars if the start container is a text node or an
54       * input element, DOM nodes otherwise.
55       */
56      private int startOffset_;
57  
58      /**
59       * The end (focus) offset; units are chars if the end container is a text node or an input
60       * element, DOM nodes otherwise.
61       */
62      private int endOffset_;
63  
64      /**
65       * Constructs an empty range without boundary points.
66       */
67      public SimpleRange() {
68          // Empty.
69      }
70  
71      /**
72       * Constructs a range enclosing the contents of the specified node.
73       *
74       * @param node the node whose contents will be enclosed by this range
75       */
76      public SimpleRange(final DomNode node) {
77          startContainer_ = node;
78          endContainer_ = node;
79          startOffset_ = 0;
80          endOffset_ = getMaxOffset(node);
81      }
82  
83      /**
84       * Constructs a collapsed range at the specified offset within a node.
85       *
86       * @param node the container node for start and end boundaries
87       * @param offset the character or node index for both start and end boundaries
88       */
89      public SimpleRange(final DomNode node, final int offset) {
90          startContainer_ = node;
91          endContainer_ = node;
92          startOffset_ = offset;
93          endOffset_ = offset;
94      }
95  
96      /**
97       * Constructs a range with the specified start and end boundary points.
98       *
99       * @param startNode the start container node
100      * @param startOffset the start offset within the start node
101      * @param endNode the end container node
102      * @param endOffset the end offset within the end node
103      */
104     public SimpleRange(final DomNode startNode, final int startOffset, final DomNode endNode, final int endOffset) {
105         startContainer_ = startNode;
106         endContainer_ = endNode;
107         startOffset_ = startOffset;
108         endOffset_ = endOffset;
109         if (startNode == endNode && startOffset > endOffset) {
110             endOffset_ = startOffset;
111         }
112     }
113 
114     /**
115      * Duplicates the contents of this range into a document fragment.
116      *
117      * @return a {@link DomDocumentFragment} containing cloned contents of this range
118      */
119     public DomDocumentFragment cloneContents() {
120         if (isCollapsed()) {
121             final SgmlPage page = startContainer_ != null ? startContainer_.getPage() : null;
122             return new DomDocumentFragment(page);
123         }
124 
125         // Clone the common ancestor.
126         final DomNode ancestor = getCommonAncestorContainer();
127         if (ancestor == null) {
128             return new DomDocumentFragment(null);
129         }
130         final DomNode ancestorClone = ancestor.cloneNode(true);
131 
132         // Find the start container and end container clones.
133         DomNode startClone = null;
134         DomNode endClone = null;
135         final DomNode start = startContainer_;
136         final DomNode end = endContainer_;
137         if (start == ancestor) {
138             startClone = ancestorClone;
139         }
140         if (end == ancestor) {
141             endClone = ancestorClone;
142         }
143         final Iterable<DomNode> descendants = ancestor.getDescendants();
144         if (startClone == null || endClone == null) {
145             final Iterator<DomNode> i = descendants.iterator();
146             final Iterator<DomNode> ci = ancestorClone.getDescendants().iterator();
147             while (i.hasNext()) {
148                 final DomNode e = i.next();
149                 final DomNode ce = ci.next();
150                 if (start == e) {
151                     startClone = ce;
152                 }
153                 if (end == e) {
154                     endClone = ce;
155                 }
156                 if (startClone != null && endClone != null) {
157                     break;
158                 }
159             }
160         }
161 
162         // Do remove from end first so that it can't affect the offset values
163 
164         // Remove everything following the selection end from the clones.
165         if (endClone == null) {
166             throw new IllegalStateException("Unable to find end node clone.");
167         }
168         deleteAfter(endClone, endOffset_);
169         for (DomNode n = endClone; n != null && n != ancestorClone; n = n.getParentNode()) {
170             while (n.getNextSibling() != null) {
171                 n.getNextSibling().remove();
172             }
173         }
174 
175         // Remove everything prior to the selection start from the clones.
176         if (startClone == null) {
177             throw new IllegalStateException("Unable to find start node clone.");
178         }
179         deleteBefore(startClone, startOffset_);
180         for (DomNode n = startClone; n != null && n != ancestorClone; n = n.getParentNode()) {
181             while (n.getPreviousSibling() != null) {
182                 n.getPreviousSibling().remove();
183             }
184         }
185 
186         final SgmlPage page = ancestor.getPage();
187         final DomDocumentFragment fragment = new DomDocumentFragment(page);
188 
189         // Append text node clone directly, or append child nodes for element containers
190         if (start == end && isOffsetChars(start)) {
191             fragment.appendChild(ancestorClone);
192         }
193         else {
194             for (final DomNode n : new ArrayList<>(ancestorClone.getChildNodes())) {
195                 fragment.appendChild(n);
196             }
197         }
198         return fragment;
199     }
200 
201     /**
202      * Produces a new {@code SimpleRange} with boundary points identical to this range.
203      *
204      * @return a cloned instance of this range
205      */
206     public SimpleRange cloneRange() {
207         return new SimpleRange(startContainer_, startOffset_, endContainer_, endOffset_);
208     }
209 
210     /**
211      * Collapses this range onto one of its boundary points.
212      *
213      * @param toStart if {@code true}, collapses the range to its start boundary;
214      *                otherwise collapses it to its end boundary
215      */
216     public void collapse(final boolean toStart) {
217         if (toStart) {
218             endContainer_ = startContainer_;
219             endOffset_ = startOffset_;
220         }
221         else {
222             startContainer_ = endContainer_;
223             startOffset_ = endOffset_;
224         }
225     }
226 
227     /**
228      * Removes the contents of this range from the document tree.
229      */
230     public void deleteContents() {
231         final DomNode ancestor = getCommonAncestorContainer();
232         if (ancestor != null) {
233             deleteContents(ancestor);
234         }
235     }
236 
237     private void deleteContents(final DomNode ancestor) {
238         // Handle single text/input node deletion separately
239         if (startContainer_ == endContainer_ && isOffsetChars(startContainer_)) {
240             final String text = getText(startContainer_);
241             if (startOffset_ < text.length() && endOffset_ <= text.length()) {
242                 final String newText = text.substring(0, startOffset_) + text.substring(endOffset_);
243                 setText(startContainer_, newText);
244             }
245             return;
246         }
247 
248         final DomNode start;
249         if (isOffsetChars(startContainer_)) {
250             start = startContainer_;
251             String text = getText(start);
252             if (startOffset_ > -1 && startOffset_ < text.length()) {
253                 text = text.substring(0, startOffset_);
254             }
255             setText(start, text);
256         }
257         else if (startContainer_.getChildNodes().getLength() > startOffset_) {
258             start = (DomNode) startContainer_.getChildNodes().item(startOffset_);
259         }
260         else {
261             start = startContainer_.getNextSibling();
262         }
263 
264         final DomNode end;
265         if (isOffsetChars(endContainer_)) {
266             end = endContainer_;
267             String text = getText(end);
268             if (endOffset_ > -1 && endOffset_ <= text.length()) {
269                 text = text.substring(endOffset_);
270             }
271             setText(end, text);
272         }
273         else if (endContainer_.getChildNodes().getLength() > endOffset_) {
274             end = (DomNode) endContainer_.getChildNodes().item(endOffset_);
275         }
276         else {
277             end = endContainer_.getNextSibling();
278         }
279 
280         boolean foundStart = false;
281         boolean started = false;
282         final Iterator<DomNode> i = ancestor.getDescendants().iterator();
283         while (i.hasNext()) {
284             final DomNode n = i.next();
285             if (n == end) {
286                 break;
287             }
288             if (n == start) {
289                 foundStart = true;
290             }
291             if (foundStart && (n != start || !isOffsetChars(startContainer_))) {
292                 started = true;
293             }
294             if (started
295                     && !n.isAncestorOf(end)
296                     && n != endContainer_
297                     && !n.isAncestorOf(endContainer_)
298                     && n != startContainer_
299                     && !n.isAncestorOf(startContainer_)) {
300                 i.remove();
301             }
302         }
303     }
304 
305     /**
306      * Moves the contents of this range from the document tree into a new DocumentFragment.
307      *
308      * @return a {@link DomDocumentFragment} containing the extracted contents
309      */
310     public DomDocumentFragment extractContents() throws DOMException {
311         final DomDocumentFragment fragment = cloneContents();
312 
313         // Remove everything inside the range from the original nodes.
314         deleteContents();
315 
316         // Build the document fragment using the cloned nodes, and return it.
317         return fragment;
318     }
319 
320     /**
321      * Indicates whether this range is collapsed (start boundary equals end boundary).
322      *
323      * @return {@code true} if start container equals end container and start offset equals end offset
324      */
325     public boolean isCollapsed() {
326         return startContainer_ != null
327                 && startContainer_ == endContainer_
328                 && startOffset_ == endOffset_;
329     }
330 
331     /**
332      * Returns the deepest common ancestor container of this range's boundary points.
333      * <p>
334      * Uses a two-pointer algorithm that requires O(1) memory by avoiding collections
335      * like {@code HashSet}. If the two boundary nodes are at different depths in the tree,
336      * switching pointers to the opposite starting node when hitting {@code null} aligns
337      * their paths so both pointers travel the exact same total distance
338      * ({@code depth(start) + depth(end)}).
339      * </p>
340      *
341      * @return the lowest common ancestor {@link DomNode}, or {@code null} if either
342      *         boundary point is {@code null} or they belong to disconnected trees
343      */
344     public DomNode getCommonAncestorContainer() {
345         if (startContainer_ == null || endContainer_ == null) {
346             return null;
347         }
348 
349         DomNode n1 = startContainer_;
350         DomNode n2 = endContainer_;
351 
352         // Traverse upwards towards root. When a pointer reaches null (root's parent),
353         // redirect it to the other node's start container.
354         //
355         // Path length for n1: startContainer -> root -> endContainer -> LCA
356         // Path length for n2: endContainer   -> root -> startContainer -> LCA
357         //
358         // Because addition is commutative, both pointers cover equal total steps
359         // and will land on the common ancestor (or null, if disconnected) at the same time.
360         while (n1 != n2) {
361             n1 = (n1 == null) ? endContainer_ : n1.getParentNode();
362             n2 = (n2 == null) ? startContainer_ : n2.getParentNode();
363         }
364 
365         return n1;
366     }
367 
368     /**
369      * Returns the node within which this range ends.
370      *
371      * @return the Node within which this range ends
372      */
373     public DomNode getEndContainer() {
374         return endContainer_;
375     }
376 
377     /**
378      * Returns the offset within the end container.
379      *
380      * @return offset within the ending node of this
381      */
382     public int getEndOffset() {
383         return endOffset_;
384     }
385 
386     /**
387      * Returns the node within which this range begins.
388      *
389      * @return the Node within which this range begins
390      */
391     public DomNode getStartContainer() {
392         return startContainer_;
393     }
394 
395     /**
396      * Returns the offset within the start container.
397      *
398      * @return offset within the starting node of this
399      */
400     public int getStartOffset() {
401         return startOffset_;
402     }
403 
404     /**
405      * Inserts a node into the Document or DocumentFragment at the start of
406      * the Range. If the container is a Text node, this will be split at the
407      * start of the Range (as if the Text node's splitText method was
408      * performed at the insertion point) and the insertion will occur
409      * between the two resulting Text nodes. Adjacent Text nodes will not be
410      * automatically merged. If the node to be inserted is a
411      * DocumentFragment node, the children will be inserted rather than the
412      * DocumentFragment node itself.
413      * @param newNode The node to insert at the start of the Range
414      */
415     public void insertNode(final DomNode newNode) {
416         if (startContainer_ instanceof DomText textNode) {
417             final DomText secondPart = textNode.splitText(startOffset_);
418             final DomNode parent = textNode.getParentNode();
419             insertNodeOrDocFragment(parent, newNode, secondPart);
420         }
421         else if (isOffsetChars(startContainer_)) {
422             final DomNode split = startContainer_.cloneNode(false);
423             String text = getText(startContainer_);
424             if (startOffset_ > -1 && startOffset_ < text.length()) {
425                 text = text.substring(0, startOffset_);
426             }
427             setText(startContainer_, text);
428             text = getText(split);
429             if (startOffset_ > -1 && startOffset_ <= text.length()) {
430                 text = text.substring(startOffset_);
431             }
432             setText(split, text);
433             insertNodeOrDocFragment(startContainer_.getParentNode(), split, startContainer_.getNextSibling());
434             insertNodeOrDocFragment(startContainer_.getParentNode(), newNode, split);
435         }
436         else {
437             insertNodeOrDocFragment(startContainer_, newNode,
438                     (DomNode) startContainer_.getChildNodes().item(startOffset_));
439         }
440 
441         setStart(newNode, 0);
442     }
443 
444     private static void insertNodeOrDocFragment(final DomNode parent, final DomNode newNode, final DomNode refNode) {
445         if (newNode instanceof DocumentFragment fragment) {
446 
447             final NodeList childNodes = fragment.getChildNodes();
448             while (childNodes.getLength() > 0) {
449                 final Node item = childNodes.item(0);
450                 parent.insertBefore(item, refNode);
451             }
452         }
453         else {
454             parent.insertBefore(newNode, refNode);
455         }
456     }
457 
458     /**
459      * Select a node and its contents.
460      * @param node The node to select.
461      */
462     public void selectNode(final DomNode node) {
463         final DomNode parent = node.getParentNode();
464         startContainer_ = parent;
465         endContainer_ = parent;
466         // find node's index among parent's children
467         int index = 0;
468         DomNode sibling = node.getPreviousSibling();
469         while (sibling != null) {
470             index++;
471             sibling = sibling.getPreviousSibling();
472         }
473         startOffset_ = index;
474         endOffset_ = index + 1;
475     }
476 
477     /**
478      * Select the contents within a node.
479      * @param node Node to select from
480      */
481     public void selectNodeContents(final DomNode node) {
482         startContainer_ = node;
483         startOffset_ = 0;
484         endContainer_ = node;
485         endOffset_ = getMaxOffset(node);
486     }
487 
488     /**
489      * Sets the end boundary point of this range.
490      *
491      * @param refNode the end container node
492      * @param offset the offset within the end node
493      */
494     public void setEnd(final DomNode refNode, final int offset) {
495         endContainer_ = refNode;
496         endOffset_ = offset;
497     }
498 
499     /**
500      * Sets the start boundary point of this range.
501      *
502      * @param refNode the start container node
503      * @param offset the offset within the start node
504      */
505     public void setStart(final DomNode refNode, final int offset) {
506         startContainer_ = refNode;
507         startOffset_ = offset;
508     }
509 
510     /**
511      * Reparents the contents of the Range to the given node and inserts the
512      * node at the position of the start of the Range.
513      * <p>
514      * Per the DOM spec, throws an {@code InvalidStateError} if any non-Text
515      * node is only partially contained by the range (i.e. the range's start
516      * or end boundary splits an element, leaving one boundary tag inside the
517      * range and the other outside). A range that spans only Text nodes, or
518      * that fully encloses every element it touches, is always valid.
519      * </p>
520      *
521      * @param newParent the node to surround the contents with
522      * @throws DOMException {@code INVALID_STATE_ERR} if any non-Text node is
523      *     partially contained in this range
524      */
525     public void surroundContents(final DomNode newParent) {
526         if (hasPartiallyContainedNonTextNode()) {
527             throw new DOMException(DOMException.INVALID_STATE_ERR, "The range partially contains a non-Text node.");
528         }
529 
530         newParent.appendChild(extractContents());
531         insertNode(newParent);
532         setStart(newParent, 0);
533         setEnd(newParent, getMaxOffset(newParent));
534     }
535 
536     /**
537      * Returns whether any non-Text node in the tree is "partially contained"
538      * by this range -- meaning it is an ancestor of exactly one boundary
539      * container, not both. Text nodes are explicitly exempted by the spec.
540      */
541     private boolean hasPartiallyContainedNonTextNode() {
542         if (startContainer_ == null || endContainer_ == null) {
543             return false;
544         }
545         if (startContainer_ == endContainer_) {
546             return false;
547         }
548 
549         // Collect all ancestors of the start container (excluding the common
550         // ancestor itself, which by definition contains both sides)
551         final DomNode common = getCommonAncestorContainer();
552 
553         // walk up from start -- any non-Text ancestor that is NOT also an
554         // ancestor of end (i.e. stops at or before the common ancestor) is
555         // partially contained
556         DomNode n = startContainer_.getParentNode();
557         while (n != null && n != common) {
558             if (!(n instanceof DomText)) {
559                 return true;
560             }
561             n = n.getParentNode();
562         }
563 
564         // walk up from end -- same check from the other side
565         n = endContainer_.getParentNode();
566         while (n != null && n != common) {
567             if (!(n instanceof DomText)) {
568                 return true;
569             }
570             n = n.getParentNode();
571         }
572 
573         return false;
574     }
575 
576     /**
577      * {@inheritDoc}
578      */
579     @Override
580     public boolean equals(final Object obj) {
581         if (!(obj instanceof SimpleRange other)) {
582             return false;
583         }
584         return new EqualsBuilder()
585             .append(startContainer_, other.startContainer_)
586             .append(endContainer_, other.endContainer_)
587             .append(startOffset_, other.startOffset_)
588             .append(endOffset_, other.endOffset_).isEquals();
589     }
590 
591     /**
592      * {@inheritDoc}
593      */
594     @Override
595     public int hashCode() {
596         return Objects.hash(startContainer_, endContainer_, startOffset_, endOffset_);
597     }
598 
599     /**
600      * {@inheritDoc}
601      */
602     @Override
603     public String toString() {
604         final DomDocumentFragment fragment = cloneContents();
605         if (fragment.getPage() != null) {
606             return fragment.asNormalizedText();
607         }
608         return "";
609     }
610 
611     private static boolean isOffsetChars(final DomNode node) {
612         return node instanceof DomText || node instanceof SelectableTextInput;
613     }
614 
615     private static String getText(final DomNode node) {
616         if (node instanceof SelectableTextInput input) {
617             return input.getText();
618         }
619         return node.getTextContent();
620     }
621 
622     private static void setText(final DomNode node, final String text) {
623         if (node instanceof SelectableTextInput input) {
624             input.setText(text);
625         }
626         else {
627             node.setTextContent(text);
628         }
629     }
630 
631     private static void deleteBefore(final DomNode node, int offset) {
632         if (isOffsetChars(node)) {
633             String text = getText(node);
634             if (offset > -1 && offset < text.length()) {
635                 text = text.substring(offset);
636             }
637             else {
638                 text = "";
639             }
640             setText(node, text);
641         }
642         else {
643             final DomNodeList<DomNode> children = node.getChildNodes();
644             for (int i = 0; i < offset && i < children.getLength(); i++) {
645                 final DomNode child = children.get(i);
646 
647                 child.remove();
648                 // Removing item at index i shifts the live list left; decrement both
649                 // i and offset so the loop re-examines the new item at this position
650                 // rather than skipping it.
651                 i--;
652                 offset--;
653             }
654         }
655     }
656 
657     private static void deleteAfter(final DomNode node, final int offset) {
658         if (isOffsetChars(node)) {
659             String text = getText(node);
660             if (offset > -1 && offset < text.length()) {
661                 text = text.substring(0, offset);
662                 setText(node, text);
663             }
664         }
665         else {
666             final DomNodeList<DomNode> children = node.getChildNodes();
667             for (int i = offset; i < children.getLength(); i++) {
668                 final DomNode child = children.get(i);
669                 child.remove();
670                 i--;
671             }
672         }
673     }
674 
675     private static int getMaxOffset(final DomNode node) {
676         return isOffsetChars(node) ? getText(node).length() : node.getChildNodes().getLength();
677     }
678 
679     /**
680      * Returns the nodes fully contained within this range in document order.
681      *
682      * @return a list with all nodes contained in this range
683      */
684     public List<DomNode> containedNodes() {
685         final DomNode ancestor = getCommonAncestorContainer();
686         if (ancestor == null) {
687             return Collections.emptyList();
688         }
689 
690         if (startContainer_ == endContainer_ && isOffsetChars(startContainer_)) {
691             return Collections.singletonList(startContainer_);
692         }
693 
694         final DomNode start;
695         if (isOffsetChars(startContainer_)) {
696             start = startContainer_;
697         }
698         else if (startContainer_.getChildNodes().getLength() > startOffset_) {
699             start = (DomNode) startContainer_.getChildNodes().item(startOffset_);
700         }
701         else {
702             start = startContainer_.getNextSibling();
703         }
704 
705         final DomNode end;
706         if (isOffsetChars(endContainer_)) {
707             end = endContainer_;
708         }
709         else if (endContainer_.getChildNodes().getLength() > endOffset_) {
710             end = (DomNode) endContainer_.getChildNodes().item(endOffset_);
711         }
712         else {
713             end = endContainer_.getNextSibling();
714         }
715 
716         boolean foundStart = false;
717         boolean started = false;
718         final List<DomNode> nodes = new ArrayList<>();
719         for (final DomNode n : ancestor.getDescendants()) {
720             if (n == end) {
721                 break;
722             }
723             if (n == start) {
724                 foundStart = true;
725             }
726             if (foundStart && (n != start || !isOffsetChars(startContainer_))) {
727                 started = true;
728             }
729             if (started
730                     && !n.isAncestorOf(end)
731                     && n != endContainer_
732                     && !n.isAncestorOf(endContainer_)
733                     && n != startContainer_
734                     && !n.isAncestorOf(startContainer_)) {
735                 nodes.add(n);
736             }
737         }
738         return nodes;
739     }
740 }