WmlUtils.java

package pro.verron.officestamper.utils.wml;

import jakarta.xml.bind.JAXBElement;
import org.docx4j.TraversalUtil;
import org.docx4j.finders.CommentFinder;
import org.docx4j.model.structure.HeaderFooterPolicy;
import org.docx4j.model.structure.SectionWrapper;
import org.docx4j.model.styles.StyleUtil;
import org.docx4j.openpackaging.exceptions.Docx4JException;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.parts.JaxbXmlPart;
import org.docx4j.openpackaging.parts.WordprocessingML.CommentsPart;
import org.docx4j.utils.TraversalUtilVisitor;
import org.docx4j.vml.CTShadow;
import org.docx4j.vml.CTTextbox;
import org.docx4j.vml.VmlShapeElements;
import org.docx4j.wml.*;
import org.docx4j.wml.Comments.Comment;
import org.jspecify.annotations.Nullable;
import org.jvnet.jaxb2_commons.ppp.Child;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import pro.verron.officestamper.utils.UtilsException;
import pro.verron.officestamper.utils.openpackaging.OpenpackagingFactory;

import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;

import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.joining;
import static org.docx4j.XmlUtils.unwrap;
import static pro.verron.officestamper.utils.wml.WmlFactory.*;

/// Utility class with methods to help in the interaction with [WordprocessingMLPackage] documents and their elements,
/// such as comments, parents, and child elements.
public final class WmlUtils {

    private static final String PRESERVE = "preserve";
    private static final Logger log = LoggerFactory.getLogger(WmlUtils.class);

    private WmlUtils() {
        throw new UtilsException("Utility class shouldn't be instantiated");
    }

    /// Attempts to find the first parent of a given child element that is an instance of the specified class within the
    /// defined search depth.
    ///
    /// @param child the [Child] element from which the search for a parent begins.
    /// @param clazz the [Class] type to match for the parent
    /// @param depth the maximum amount levels to traverse up the parent hierarchy
    /// @param <T> the type of the parent class to search for
    ///
    /// @return an [Optional] containing the first parent matching the specified class, or an empty [Optional] if no
    ///         match found.
    public static <T> Optional<T> getFirstParentWithClass(Child child, Class<T> clazz, int depth) {
        var parent = child.getParent();
        var currentDepth = 0;
        while (currentDepth <= depth) {
            currentDepth++;
            if (parent == null) return Optional.empty();
            if (clazz.isInstance(parent)) return Optional.of(clazz.cast(parent));
            if (parent instanceof Child next) parent = next.getParent();
        }
        return Optional.empty();
    }

    /// Extracts a list of comment elements from the specified [WordprocessingMLPackage] document.
    ///
    /// @param document the [WordprocessingMLPackage] document from which to extract comment elements
    ///
    /// @return a list of [Child] objects representing the extracted comment elements
    public static List<Child> extractCommentElements(WordprocessingMLPackage document) {
        var commentFinder = new CommentFinder();
        TraversalUtil.visit(document, true, commentFinder);
        return commentFinder.getCommentElements();
    }

    /// Finds a comment with the given ID in the specified [WordprocessingMLPackage] document.
    ///
    /// @param document the [WordprocessingMLPackage] document to search for the comment
    /// @param id the ID of the comment to find
    ///
    /// @return an [Optional] containing the [Comment] if found, or an empty [Optional] if not found.
    public static Optional<Comment> findComment(WordprocessingMLPackage document, BigInteger id) {
        var name = OpenpackagingFactory.newPartName("/word/comments.xml");
        var parts = document.getParts();
        var wordComments = (CommentsPart) parts.get(name);
        var comments = getComments(wordComments);
        return comments.getComment()
                       .stream()
                       .filter(idEqual(id))
                       .findFirst();
    }

    private static Comments getComments(CommentsPart wordComments) {
        try {
            return wordComments.getContents();
        } catch (Docx4JException e) {
            throw new UtilsException(e);
        }
    }

    private static Predicate<Comment> idEqual(BigInteger id) {
        return comment -> {
            var commentId = comment.getId();
            return commentId.equals(id);
        };
    }


    /// Removes the specified child element from its parent container. Depending on the type of the parent element, the
    /// removal process is delegated to the appropriate helper method. If the child is contained within a table cell and
    /// the cell is empty after removal, an empty paragraph is added to the cell.
    ///
    /// @param child the [Child] element to be removed
    ///
    /// @throws UtilsException if the parent of the child element is of an unexpected type
    public static void remove(Child child) {
        switch (child.getParent()) {
            case ContentAccessor parent -> remove(parent, child);
            case CTFootnotes parent -> remove(parent, child);
            case CTEndnotes parent -> remove(parent, child);
            case SdtRun parent -> remove(parent, child);
            default -> throw new UtilsException("Unexpected value: " + child.getParent());
        }
        if (child.getParent() instanceof Tc cell) ensureValidity(cell);
    }

    private static void remove(ContentAccessor parent, Child child) {
        var siblings = parent.getContent();
        var iterator = siblings.listIterator();
        while (iterator.hasNext()) {
            if (equals(iterator.next(), child)) {
                iterator.remove();
                break;
            }
        }
    }

    @SuppressWarnings("SuspiciousMethodCalls")
    private static void remove(CTFootnotes parent, Child child) {
        parent.getFootnote()
              .remove(child);
    }

    @SuppressWarnings("SuspiciousMethodCalls")
    private static void remove(CTEndnotes parent, Child child) {
        parent.getEndnote()
              .remove(child);
    }

    private static void remove(SdtRun parent, Child child) {
        parent.getSdtContent()
              .getContent()
              .remove(child);
    }

    /// Utility method to ensure the validity of a table cell by adding an empty paragraph if necessary.
    ///
    /// @param cell the [Tc] to be checked and updated.
    public static void ensureValidity(Tc cell) {
        if (!containsAnElementOfAnyClasses(cell.getContent(), P.class, Tbl.class)) {
            addEmptyParagraph(cell);
        }
    }

    private static boolean equals(Object o1, Object o2) {
        if (o1 instanceof JAXBElement<?> e1) o1 = e1.getValue();
        if (o2 instanceof JAXBElement<?> e2) o2 = e2.getValue();
        return Objects.equals(o1, o2);
    }

    private static boolean containsAnElementOfAnyClasses(Collection<Object> collection, Class<?>... classes) {
        return collection.stream()
                         .anyMatch(element -> isAnElementOfAnyClasses(element, classes));
    }

    private static void addEmptyParagraph(Tc cell) {
        var emptyParagraph = WmlFactory.newParagraph();
        var cellContent = cell.getContent();
        cellContent.add(emptyParagraph);
    }

    private static boolean isAnElementOfAnyClasses(Object element, Class<?>... classes) {
        for (var clazz : classes) {
            if (clazz.isInstance(unwrapJAXBElement(element))) return true;
        }
        return false;
    }

    private static Object unwrapJAXBElement(Object element) {
        return element instanceof JAXBElement<?> jaxbElement ? jaxbElement.getValue() : element;
    }

    /// Extracts textual content from a given object, handling various object types, such as runs, text elements, and
    /// other specific constructs. The method accounts for different cases, such as run breaks, hyphens, and other
    /// document-specific constructs, and converts them into corresponding string representations.
    ///
    /// @param content the object from which text content is to be extracted. This could be of various types
    ///         such as [R], [JAXBElement], [Text] or specific document elements.
    ///
    /// @return a string representation of the extracted textual content. If the object's type is not handled, an empty
    ///         string is returned.
    public static String asString(Object content) {
        return switch (content) {
            case P paragraph -> asString(paragraph.getContent());
            case R run -> asString(run.getContent());
            case JAXBElement<?> jaxbElement when jaxbElement.getName()
                                                            .getLocalPart()
                                                            .equals("instrText") -> "<instrText>";
            case JAXBElement<?> jaxbElement when !jaxbElement.getName()
                                                             .getLocalPart()
                                                             .equals("instrText") -> asString(jaxbElement.getValue());
            case Text text -> asString(text);
            case R.Tab _ -> "\t";
            case R.Cr _ -> "\n";
            case Br br when br.getType() == null -> "\n";
            case Br br when br.getType() == STBrType.PAGE -> "\n";
            case Br br when br.getType() == STBrType.COLUMN -> "\n";
            case Br br when br.getType() == STBrType.TEXT_WRAPPING -> "\n";

            case R.NoBreakHyphen _ -> "‑";
            case R.SoftHyphen _ -> "\u00AD";
            case R.LastRenderedPageBreak _, R.AnnotationRef _, R.CommentReference _, Drawing _ -> "";
            case FldChar _ -> "<fldchar>";
            case CTFtnEdnRef ref -> "<ref(%s)>".formatted(ref.getId());
            case R.Sym sym -> "<sym(%s, %s)>".formatted(sym.getFont(), sym.getChar());
            case List<?> list -> list.stream()
                                     .map(WmlUtils::asString)
                                     .collect(joining());
            case ProofErr _, CTShadow _ -> "";
            case SdtRun sdtRun -> asString(sdtRun.getSdtContent());
            case ContentAccessor contentAccessor -> asString(contentAccessor.getContent());
            case Pict pict -> asString(pict.getAnyAndAny());
            case VmlShapeElements vmlShapeElements -> asString(vmlShapeElements.getEGShapeElements());
            case CTTextbox textbox -> asString(textbox.getTxbxContent());
            case CommentRangeStart _, CommentRangeEnd _ -> "";
            default -> {
                log.debug("Unhandled object type: {}", content.getClass());
                yield "";
            }
        };
    }

    private static String asString(Text text) {
        // According to specs, the 'space' value can be empty or 'preserve'.
        // In the first case, we are supposed to ignore spaces around the 'text' value.
        var value = text.getValue();
        var space = text.getSpace();
        return Objects.equals(space, PRESERVE) ? value : value.trim();
    }

    /// Inserts a smart tag with the specified element type into the given paragraph at the position of the expression.
    ///
    /// @param element the element type for the smart tag
    /// @param paragraph the [P] paragraph to insert the smart tag into
    /// @param expression the expression to replace with the smart tag
    /// @param start the start index of the expression
    /// @param end the end index of the expression
    ///
    /// @return a list of [Object] representing the updated content
    public static List<Object> insertSmartTag(String element, P paragraph, String expression, int start, int end) {
        var run = newRun(expression);
        var smartTag = newSmartTag("officestamper", newCtAttr("type", element), run);
        findFirstAffectedRunPr(paragraph, start, end).ifPresent(run::setRPr);
        return replace(paragraph, List.of(smartTag), start, end);
    }

    /// Finds the first affected run properties within the specified range.
    ///
    /// @param contentAccessor the [ContentAccessor] to search in
    /// @param start the start index of the range
    /// @param end the end index of the range
    ///
    /// @return an [Optional] containing the [RPr] if found, or an empty [Optional] if not found
    public static Optional<RPr> findFirstAffectedRunPr(ContentAccessor contentAccessor, int start, int end) {
        var iterator = new DocxIterator(contentAccessor).selectClass(R.class);
        var runs = StandardRun.wrap(iterator);

        var affectedRuns = runs.stream()
                               .filter(run -> run.isTouchedByRange(start, end))
                               .toList();

        var firstRun = affectedRuns.getFirst();
        var firstRunPr = firstRun.getPr();
        return Optional.ofNullable(firstRunPr);
    }

    /// Replaces content within the specified range with the provided insert objects.
    ///
    /// @param contentAccessor the [ContentAccessor] in which to replace content
    /// @param insert the list of objects to insert
    /// @param startIndex the start index of the range to replace
    /// @param endIndex the end index of the range to replace
    ///
    /// @return a list of [Object] representing the updated content
    public static List<Object> replace(
            ContentAccessor contentAccessor,
            List<Object> insert,
            int startIndex,
            int endIndex
    ) {
        var iterator = new DocxIterator(contentAccessor).selectClass(R.class);
        var runs = StandardRun.wrap(iterator);
        var affectedRuns = runs.stream()
                               .filter(run -> run.isTouchedByRange(startIndex, endIndex))
                               .toList();

        var firstRun = affectedRuns.getFirst();
        var firstR = firstRun.run();
        var firstSiblings = ((ContentAccessor) firstR.getParent()).getContent();
        var firstIndex = firstSiblings.indexOf(firstRun.run());

        boolean singleRun = affectedRuns.size() == 1;
        if (singleRun) {
            boolean expressionSpansCompleteRun = endIndex - startIndex == firstRun.length();
            boolean expressionAtStartOfRun = startIndex == firstRun.startIndex();
            boolean expressionAtEndOfRun = endIndex == firstRun.endIndex();
            boolean expressionWithinRun = startIndex > firstRun.startIndex() && endIndex <= firstRun.endIndex();

            if (expressionSpansCompleteRun) {
                firstRun.replace(startIndex, endIndex, "");
                firstSiblings.addAll(firstIndex, insert);
            }
            else if (expressionAtStartOfRun) {
                firstRun.replace(startIndex, endIndex, "");
                firstSiblings.addAll(firstIndex, insert);
            }
            else if (expressionAtEndOfRun) {
                firstRun.replace(startIndex, endIndex, "");
                firstSiblings.addAll(firstIndex + 1, insert);
            }
            else if (expressionWithinRun) {
                var originalRun = firstRun.run();
                var originalRPr = originalRun.getRPr();
                var newStartRun = create(firstRun.left(startIndex), originalRPr);
                var newEndRun = create(firstRun.right(endIndex), originalRPr);
                firstSiblings.remove(firstIndex);
                firstSiblings.addAll(firstIndex, wrap(newStartRun, insert, newEndRun));
            }
        }
        else {
            StandardRun lastRun = affectedRuns.getLast();
            removeExpression(firstSiblings, firstRun, startIndex, endIndex, lastRun, affectedRuns);
            // add replacement run between first and last run
            firstSiblings.addAll(firstIndex + 1, insert);
        }
        return new ArrayList<>(contentAccessor.getContent());
    }

    /// Creates a new run with the specified text, and the specified run style.
    ///
    /// @param text the initial text of the [R].
    /// @param rPr the [RPr] to apply to the run
    ///
    /// @return the newly created [R].
    public static R create(String text, RPr rPr) {
        R newStartRun = newRun(text);
        newStartRun.setRPr(rPr);
        return newStartRun;
    }

    private static Collection<?> wrap(R prefix, Collection<?> elements, R suffix) {
        var merge = new ArrayList<>();
        merge.add(prefix);
        merge.addAll(elements);
        merge.add(suffix);
        return merge;
    }

    private static void removeExpression(
            List<Object> contents,
            StandardRun firstRun,
            int matchStartIndex,
            int matchEndIndex,
            StandardRun lastRun,
            List<StandardRun> affectedRuns
    ) {
        // remove the expression from the first run
        firstRun.replace(matchStartIndex, matchEndIndex, "");
        // remove all runs between first and last
        for (StandardRun run : affectedRuns) {
            if (!Objects.equals(run, firstRun) && !Objects.equals(run, lastRun)) {
                contents.remove(run.run());
            }
        }
        // remove the expression from the last run
        lastRun.replace(matchStartIndex, matchEndIndex, "");
    }

    /// Creates a new run with the specified text and inherits the style of the parent paragraph.
    ///
    /// @param text the initial text of the [R].
    /// @param paragraphPr the [PPr] to apply to the run
    ///
    /// @return the newly created [R].
    public static R create(String text, PPr paragraphPr) {
        R run = newRun(text);
        applyParagraphStyle(run, paragraphPr);
        return run;
    }

    /// Applies the style of the given paragraph to the given content object (if the content object is a [R]).
    ///
    /// @param run the [R] to which the style should be applied.
    /// @param paragraphPr the [PPr] containing the style to apply
    public static void applyParagraphStyle(R run, @Nullable PPr paragraphPr) {
        if (paragraphPr == null) return;
        var runPr = paragraphPr.getRPr();
        if (runPr == null) return;
        RPr runProperties = new RPr();
        StyleUtil.apply(runPr, runProperties);
        run.setRPr(runProperties);
    }

    /// Sets the text of the given run to the given value.
    ///
    /// @param run the [R] whose text to change.
    /// @param text the text to set.
    public static void setText(R run, String text) {
        run.getContent()
           .clear();
        Text textObj = newText(text);
        run.getContent()
           .add(textObj);
    }

    /// Replaces all occurrences of the specified expression with the provided run objects.
    ///
    /// @param contentAccessor the [ContentAccessor] in which to replace the expression
    /// @param expression the expression to replace
    /// @param insert the list of objects to insert
    /// @param onRPr a consumer to handle [RPr] properties
    ///
    /// @return a list of [Object] representing the updated content
    public static List<Object> replaceExpressionWithRun(
            ContentAccessor contentAccessor,
            String expression,
            List<Object> insert,
            Consumer<RPr> onRPr
    ) {
        var text = asString(contentAccessor);
        int matchStartIndex = text.indexOf(expression);
        if (matchStartIndex == -1) /*nothing to replace*/ return contentAccessor.getContent();
        int matchEndIndex = matchStartIndex + expression.length();
        findFirstAffectedRunPr(contentAccessor, matchStartIndex, matchEndIndex).ifPresent(onRPr);
        return replace(contentAccessor, insert, matchStartIndex, matchEndIndex);
    }

    /// Checks if the given [CTSmartTagRun] contains an element that matches the expected element.
    ///
    /// @param tag the [CTSmartTagRun] object to be evaluated
    /// @param expectedElement the expected element to compare against
    ///
    /// @return true if the actual element of the given tag matches the expected element, false otherwise
    public static boolean isTagElement(CTSmartTagRun tag, String expectedElement) {
        var actualElement = tag.getElement();
        return Objects.equals(expectedElement, actualElement);
    }

    /// Sets or updates an attribute for the specified smart tag. This method ensures that the provided attribute
    /// key-value pair is added to the smart tag's attribute list. If the attribute already exists, its value is
    /// updated. If the smart tag or its attribute metadata is null, they are initialized.
    ///
    /// @param smartTag the smart tag object to modify
    /// @param attributeKey the key of the attribute to set or update
    /// @param attributeValue the value to assign to the specified attribute key
    public static void setTagAttribute(CTSmartTagRun smartTag, String attributeKey, String attributeValue) {
        var smartTagPr = smartTag.getSmartTagPr();
        if (smartTagPr == null) {
            smartTagPr = new CTSmartTagPr();
            smartTag.setSmartTagPr(smartTagPr);
        }
        var smartTagPrAttr = smartTagPr.getAttr();
        if (smartTagPrAttr == null) {
            smartTagPrAttr = new ArrayList<>();
            smartTag.setSmartTagPr(smartTagPr);
        }
        for (CTAttr attribute : smartTagPrAttr) {
            if (attributeKey.equals(attribute.getName())) {
                attribute.setVal(attributeValue);
                return;
            }
        }
        var ctAttr = newAttribute(attributeKey, attributeValue);
        smartTagPrAttr.add(ctAttr);
    }

    /// Creates a new attribute object with the specified key and value.
    ///
    /// @param attributeKey the key for the new attribute
    /// @param attributeValue the value for the new attribute
    /// @return a CTAttr object representing the new attribute
    public static CTAttr newAttribute(String attributeKey, String attributeValue) {
        return newCtAttr(attributeKey, attributeValue);
    }

    /// Deletes all elements associated with the specified comment from the provided list of items.
    ///
    /// @param commentId the ID of the comment to be deleted
    /// @param items the list of items from which elements associated with the comment will be deleted
    public static void deleteCommentFromElements(BigInteger commentId, List<Object> items) {
        record DeletableItems(List<Object> container, List<Object> items) {
            static List<DeletableItems> findAll(List<Object> items, BigInteger commentId) {
                Predicate<BigInteger> predicate = bi -> Objects.equals(bi, commentId);
                List<DeletableItems> elementsToRemove = new ArrayList<>();
                items.forEach(item -> {
                    Object unwrapped = unwrap(item);
                    // Recursively finds deletable items associated with comment ID
                    elementsToRemove.addAll(switch (unwrapped) {
                        case CTSmartTagRun str when str.getContent()
                                                       .stream()
                                                       .anyMatch(i -> i instanceof CommentRangeStart crs
                                                                      && predicate.test(crs.getId())) ->
                                from(items, item);
                        case CommentRangeStart crs when predicate.test(crs.getId()) -> from(items, item);
                        case CommentRangeEnd cre when predicate.test(cre.getId()) -> from(items, item);
                        case R.CommentReference rcr when predicate.test(rcr.getId()) -> from(items, item);
                        case ContentAccessor ca -> findAll(ca, commentId);
                        case SdtRun sdtRun -> findAll(sdtRun, commentId);
                        default -> emptyList();
                    });
                });
                return elementsToRemove;
            }

            private static Collection<DeletableItems> findAll(SdtRun sdtRun, BigInteger commentId) {
                return findAll(sdtRun.getSdtContent(), commentId);
            }

            private static Collection<DeletableItems> findAll(ContentAccessor ca, BigInteger commentId) {
                return findAll(ca.getContent(), commentId);
            }

            private static List<DeletableItems> from(List<Object> items, Object item) {
                return Collections.singletonList(new DeletableItems(items, List.of(item)));
            }
        }
        DeletableItems.findAll(items, commentId)
                      .forEach(p -> p.container.removeAll(p.items));
    }

    /// Visits the document's main content, header, footer, footnotes, and endnotes using the specified visitor.
    ///
    /// @param document the WordprocessingMLPackage representing the document to be visited
    /// @param visitor the TraversalUtilVisitor to be applied to each relevant part of the document
    public static void visitDocument(WordprocessingMLPackage document, TraversalUtilVisitor<?> visitor) {
        var mainDocumentPart = document.getMainDocumentPart();
        TraversalUtil.visit(mainDocumentPart, visitor);
        WmlUtils.streamHeaderFooterPart(document)
                .forEach(f -> TraversalUtil.visit(f, visitor));
        WmlUtils.visitPartIfExists(visitor, mainDocumentPart.getFootnotesPart());
        WmlUtils.visitPartIfExists(visitor, mainDocumentPart.getEndNotesPart());
    }

    private static Stream<Object> streamHeaderFooterPart(WordprocessingMLPackage document) {
        return document.getDocumentModel()
                       .getSections()
                       .stream()
                       .map(SectionWrapper::getHeaderFooterPolicy)
                       .flatMap(WmlUtils::extractHeaderFooterParts);
    }

    private static void visitPartIfExists(TraversalUtilVisitor<?> visitor, @Nullable JaxbXmlPart<?> part) {
        Optional.ofNullable(part)
                .map(WmlUtils::extractContent)
                .ifPresent(c -> TraversalUtil.visit(c, visitor));
    }

    private static Stream<JaxbXmlPart<?>> extractHeaderFooterParts(HeaderFooterPolicy hfp) {
        Stream.Builder<JaxbXmlPart<?>> builder = Stream.builder();
        ofNullable(hfp.getFirstHeader()).ifPresent(builder::add);
        ofNullable(hfp.getDefaultHeader()).ifPresent(builder::add);
        ofNullable(hfp.getEvenHeader()).ifPresent(builder::add);
        ofNullable(hfp.getFirstFooter()).ifPresent(builder::add);
        ofNullable(hfp.getDefaultFooter()).ifPresent(builder::add);
        ofNullable(hfp.getEvenFooter()).ifPresent(builder::add);
        return builder.build();
    }

    private static Object extractContent(JaxbXmlPart<?> jaxbXmlPart) {
        try {
            return jaxbXmlPart.getContents();
        } catch (Docx4JException e) {
            throw new UtilsException(e);
        }
    }

    /// @param startIndex the start index of the run relative to the containing paragraph.
    /// @param run the [R] run itself.
    private record StandardRun(int startIndex, R run) {

        /// Initializes a list of [StandardRun] objects based on the given iterator of [R] objects.
        ///
        /// @param iterator the iterator of [R] objects to be processed into [StandardRun] instances
        ///
        /// @return a list of [StandardRun] objects created from the given iterator
        public static List<StandardRun> wrap(Iterator<R> iterator) {
            var index = 0;
            var runList = new ArrayList<StandardRun>();
            while (iterator.hasNext()) {
                var run = iterator.next();
                var currentRun = new StandardRun(index, run);
                runList.add(currentRun);
                index += currentRun.length();
            }
            return runList;
        }

        /// Calculates the length of the text content of this run.
        ///
        /// @return the length of the text in the current run.
        public int length() {
            return getText().length();
        }

        /// Returns the text string of a run.
        ///
        /// @return [String] representation of the run.
        public String getText() {
            return asString(run);
        }

        /// Retrieves the properties associated with this run.
        ///
        /// @return the [RPr] object representing the properties of the run.
        public RPr getPr() {
            return run.getRPr();
        }

        /// Determines whether the current run is affected by the specified range of global start and end indices. A run
        /// is considered "touched" if any part of it overlaps with the given range.
        ///
        /// @param globalStartIndex the global start index of the range.
        /// @param globalEndIndex the global end index of the range.
        ///
        /// @return `true` if the current run is touched by the specified range; `false` otherwise.
        public boolean isTouchedByRange(int globalStartIndex, int globalEndIndex) {
            return startsInRange(globalStartIndex, globalEndIndex) || endsInRange(globalStartIndex, globalEndIndex)
                   || englobesRange(globalStartIndex, globalEndIndex);
        }

        private boolean startsInRange(int globalStartIndex, int globalEndIndex) {
            return globalStartIndex < startIndex && startIndex <= globalEndIndex;
        }

        private boolean endsInRange(int globalStartIndex, int globalEndIndex) {
            return globalStartIndex < endIndex() && endIndex() <= globalEndIndex;
        }

        private boolean englobesRange(int globalStartIndex, int globalEndIndex) {
            return startIndex <= globalStartIndex && globalEndIndex <= endIndex();
        }

        /// Calculates the end index of the current run based on its start index and length.
        ///
        /// @return the end index of the run.
        public int endIndex() {
            return startIndex + length();
        }

        /// Replaces the substring starting at the given index with the given replacement string.
        ///
        /// @param globalStartIndex the global index at which to start the replacement.
        /// @param globalEndIndex the global index at which to end the replacement.
        /// @param replacement the string to replace the substring at the specified global index.
        public void replace(int globalStartIndex, int globalEndIndex, String replacement) {
            var text = left(globalStartIndex) + replacement + right(globalEndIndex);
            setText(run, text);
        }

        /// Extracts a substring of the run's text, starting from the beginning and extending up to the localized index
        /// of the specified global end index.
        ///
        /// @param globalEndIndex the global end index used to determine the cutoff point for the extracted
        ///         substring.
        ///
        /// @return a substring of the run's text, starting at the beginning and ending at the specified localized
        ///         index.
        public String left(int globalEndIndex) {
            return getText().substring(0, localize(globalEndIndex));
        }

        /// Extracts a substring of the run's text, starting from the localized index of the specified global start
        /// index to the end of the run's text.
        ///
        /// @param globalStartIndex the global index specifying the starting point for the substring in the
        ///         run's text.
        ///
        /// @return a substring of the run's text starting from the localized index corresponding to the provided global
        ///         start index.
        public String right(int globalStartIndex) {
            return getText().substring(localize(globalStartIndex));
        }

        /// Converts a global index to a local index within the context of this run. (meaning the index relative to
        /// multiple aggregated runs)
        ///
        /// @param globalIndex the global index to convert.
        ///
        /// @return the local index corresponding to the given global index.
        private int localize(int globalIndex) {
            if (globalIndex < startIndex) return 0;
            else if (globalIndex > endIndex()) return length();
            else return globalIndex - startIndex;
        }

        /// Gets the start index of this run.
        ///
        /// @return the start index of the run relative to the containing paragraph.
        @Override
        public int startIndex() {return startIndex;}

        /// Gets the underlying run object.
        ///
        /// @return the [R] run object.
        @Override
        public R run() {return run;}
    }
}