chromium/ui/android/java/src/org/chromium/ui/text/SpanApplier.java

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.ui.text;

import android.text.SpannableString;

import androidx.annotation.Nullable;

import java.util.Arrays;

/**
 * Applies spans to an HTML-looking string and returns the resulting SpannableString.
 * Note: This does not support duplicate, nested or overlapping spans.
 *
 * Example:
 *
 *   String input = "Click to view the <tos>terms of service</tos> or <pn>privacy notice</pn>";
 *   ClickableSpan tosSpan = ...;
 *   ClickableSpan privacySpan = ...;
 *   SpannableString output = SpanApplier.applySpans(input,
 *           new Span("<tos>", "</tos>", tosSpan), new Span("<pn>", "</pn>", privacySpan));
 */
public class SpanApplier {
    private static final int INVALID_INDEX = -1;

    /** Associates a span with the range of text between a start and an end tag. */
    public static final class SpanInfo implements Comparable<SpanInfo> {
        final String mStartTag;
        final String mEndTag;
        final @Nullable Object[] mSpans;
        int mStartTagIndex;
        int mEndTagIndex;

        /**
         * @param startTag The start tag, e.g. "<tos>".
         * @param endTag The end tag, e.g. "</tos>".
         * @param span The span to apply to the text between the start and end tags. May be null,
         *         then SpanApplier will not apply any span.
         */
        public SpanInfo(String startTag, String endTag, @Nullable Object span) {
            mStartTag = startTag;
            mEndTag = endTag;
            mSpans = span == null ? null : new Object[] {span};
        }

        /**
         * @param startTag The start tag, e.g. "<tos>".
         * @param endTag The end tag, e.g. "</tos>".
         * @param spans A vararg list of spans to be applied.
         */
        public SpanInfo(String startTag, String endTag, Object... spans) {
            mStartTag = startTag;
            mEndTag = endTag;
            mSpans = spans;
        }

        @Override
        public int compareTo(SpanInfo other) {
            return this.mStartTagIndex < other.mStartTagIndex
                    ? -1
                    : (this.mStartTagIndex == other.mStartTagIndex ? 0 : 1);
        }

        @Override
        public boolean equals(Object other) {
            if (!(other instanceof SpanInfo)) return false;

            return compareTo((SpanInfo) other) == 0;
        }

        @Override
        public int hashCode() {
            return 0;
        }
    }

    /**
     * Applies spans to an HTML-looking string and returns the resulting SpannableString.
     * If a span cannot be applied (e.g. because the start tag isn't in the input string), then
     * a RuntimeException will be thrown. Nested or duplicated spans are also regarded as an error.
     *
     * @param input The input string.
     * @param spans The Spans which will be applied to the string.
     * @return A SpannableString with the given spans applied.
     * @throws IllegalArgumentException if the span cannot be applied.
     */
    public static SpannableString applySpans(String input, SpanInfo... spans) {
        setSpanInfoIndices(input, spans);

        // Copy the input text to the output, but omit the start and end tags.
        // Update startTagIndex and endTagIndex for each Span as we go.
        int inputIndex = 0;
        StringBuilder output = new StringBuilder(input.length());

        for (SpanInfo span : spans) {
            validateSpanInfo(span, input, inputIndex);
            output.append(input, inputIndex, span.mStartTagIndex);
            inputIndex = span.mStartTagIndex + span.mStartTag.length();
            span.mStartTagIndex = output.length();

            output.append(input, inputIndex, span.mEndTagIndex);
            inputIndex = span.mEndTagIndex + span.mEndTag.length();
            span.mEndTagIndex = output.length();
        }
        output.append(input, inputIndex, input.length());

        SpannableString spannableString = new SpannableString(output);
        for (SpanInfo span : spans) {
            if (span.mStartTagIndex == INVALID_INDEX
                    || span.mSpans == null
                    || span.mSpans.length == 0) {
                continue;
            }

            for (Object s : span.mSpans) {
                if (s == null) continue;
                spannableString.setSpan(s, span.mStartTagIndex, span.mEndTagIndex, 0);
            }
        }

        return spannableString;
    }

    /**
     * Sets up the given {@link SpanInfo} entries to index into the given input and
     * sorted by appearance order.
     */
    private static void setSpanInfoIndices(String input, SpanInfo... spans) {
        for (SpanInfo span : spans) {
            // Set the start/end indices, and if not found, set to INVALID_INDEX.
            span.mStartTagIndex = input.indexOf(span.mStartTag);
            span.mEndTagIndex =
                    input.indexOf(span.mEndTag, span.mStartTagIndex + span.mStartTag.length());
        }

        // Sort the spans from first to last in the order they appear in the input string.
        Arrays.sort(spans);
    }

    /**
     * Validate the given span making sure there are start and end tags that index before
     * the input limit.
     * @param span SpanInfo object defining one span.
     * @param input Input string containing the span.
     * @param spanIndexLimit The mini start position the given span can have in the input string.
     * @throws IllegalArgumentException if the span is not valid.
     */
    private static void validateSpanInfo(SpanInfo span, String input, int spanIndexLimit) {
        // Fail if there is a span without a start or end tag or if there are nested
        // or overlapping spans.
        if (span.mStartTagIndex == INVALID_INDEX
                || span.mEndTagIndex == INVALID_INDEX
                || span.mStartTagIndex < spanIndexLimit) {
            span.mStartTagIndex = -1;
            String error =
                    String.format(
                            "Input string is missing tags %s%s: %s",
                            span.mStartTag, span.mEndTag, input);
            throw new IllegalArgumentException(error);
        }
    }

    /**
     * Removes spans defined as an HTML-looking string from the input string. Note that it is
     * NOT the attribute defined by the SpanInfo but the actual text itself that is removed.
     * If a span cannot be removed (e.g. because the start tag isn't in the input string), then
     * a RuntimeException will be thrown. Nested or duplicated spans are also regarded as an error.
     *
     * @param input The input string.
     * @param spans The Spans which will be removed from the string.
     * @return A String with the given spans all removed.
     * @throws IllegalArgumentException if the span cannot be found.
     */
    public static String removeSpanText(String input, SpanInfo... spans) {
        setSpanInfoIndices(input, spans);

        // Copy the input text to the output, but omit span text including the start and end tags.
        int inputIndex = 0;
        StringBuilder output = new StringBuilder(input.length());

        for (SpanInfo span : spans) {
            validateSpanInfo(span, input, inputIndex);
            output.append(input, inputIndex, span.mStartTagIndex);
            inputIndex = span.mEndTagIndex + span.mEndTag.length();
        }
        output.append(input, inputIndex, input.length());
        return output.toString();
    }
}