diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/SpanUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/text/SpanUtil.java new file mode 100644 index 0000000000..9e9f350dd7 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/SpanUtil.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.text; + +import android.text.Spannable; +import android.text.style.ForegroundColorSpan; + +/** + * Utility methods for Android span + * styling. + */ +public final class SpanUtil { + + /** + * Adds {@code span} to {@code spannable} between {@code start} and {@code end}, removing any + * existing spans of the same type and with the same indices and flags. + * + *

This is useful for types of spans that don't make sense to duplicate and where the + * evaluation order might have an unexpected impact on the final text, e.g. {@link + * ForegroundColorSpan}. + * + * @param spannable The {@link Spannable} to add {@code span} to. + * @param span The span object to be added. + * @param start The start index to add the new span at. + * @param end The end index to add the new span at. + * @param spanFlags The flags to pass to {@link Spannable#setSpan(Object, int, int, int)}. + */ + public static void addOrReplaceSpan( + Spannable spannable, Object span, int start, int end, int spanFlags) { + Object[] existingSpans = spannable.getSpans(start, end, span.getClass()); + for (Object existingSpan : existingSpans) { + if (spannable.getSpanStart(existingSpan) == start + && spannable.getSpanEnd(existingSpan) == end + && spannable.getSpanFlags(existingSpan) == spanFlags) { + spannable.removeSpan(existingSpan); + } + } + spannable.setSpan(span, start, end, spanFlags); + } + + private SpanUtil() {} +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java index 21333081c6..25395431de 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.text.ttml; -import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.style.AbsoluteSizeSpan; @@ -27,6 +26,7 @@ import android.text.style.StrikethroughSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; +import com.google.android.exoplayer2.text.SpanUtil; import java.util.Map; /** @@ -77,32 +77,60 @@ import java.util.Map; builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.hasFontColor()) { - builder.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + SpanUtil.addOrReplaceSpan( + builder, + new ForegroundColorSpan(style.getFontColor()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.hasBackgroundColor()) { - builder.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + SpanUtil.addOrReplaceSpan( + builder, + new BackgroundColorSpan(style.getBackgroundColor()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.getFontFamily() != null) { - builder.setSpan(new TypefaceSpan(style.getFontFamily()), start, end, + SpanUtil.addOrReplaceSpan( + builder, + new TypefaceSpan(style.getFontFamily()), + start, + end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.getTextAlign() != null) { - builder.setSpan(new AlignmentSpan.Standard(style.getTextAlign()), start, end, + SpanUtil.addOrReplaceSpan( + builder, + new AlignmentSpan.Standard(style.getTextAlign()), + start, + end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } switch (style.getFontSizeUnit()) { case TtmlStyle.FONT_SIZE_UNIT_PIXEL: - builder.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, + SpanUtil.addOrReplaceSpan( + builder, + new AbsoluteSizeSpan((int) style.getFontSize(), true), + start, + end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TtmlStyle.FONT_SIZE_UNIT_EM: - builder.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, + SpanUtil.addOrReplaceSpan( + builder, + new RelativeSizeSpan(style.getFontSize()), + start, + end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TtmlStyle.FONT_SIZE_UNIT_PERCENT: - builder.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, + SpanUtil.addOrReplaceSpan( + builder, + new RelativeSizeSpan(style.getFontSize() / 100), + start, + end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TtmlStyle.UNSPECIFIED: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index fe36043800..f62b073f60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -15,11 +15,11 @@ */ package com.google.android.exoplayer2.text.webvtt; +import static com.google.android.exoplayer2.text.SpanUtil.addOrReplaceSpan; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.graphics.Typeface; import android.text.Layout; -import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.SpannedString; @@ -535,7 +535,12 @@ public final class WebvttCueParser { return; } if (style.getStyle() != WebvttCssStyle.UNSPECIFIED) { - addOrReplaceSpan(spannedText, new StyleSpan(style.getStyle()), start, end); + addOrReplaceSpan( + spannedText, + new StyleSpan(style.getStyle()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.isLinethrough()) { spannedText.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -544,29 +549,62 @@ public final class WebvttCueParser { spannedText.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.hasFontColor()) { - addOrReplaceSpan(spannedText, new ForegroundColorSpan(style.getFontColor()), start, end); + addOrReplaceSpan( + spannedText, + new ForegroundColorSpan(style.getFontColor()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.hasBackgroundColor()) { addOrReplaceSpan( - spannedText, new BackgroundColorSpan(style.getBackgroundColor()), start, end); + spannedText, + new BackgroundColorSpan(style.getBackgroundColor()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (style.getFontFamily() != null) { - addOrReplaceSpan(spannedText, new TypefaceSpan(style.getFontFamily()), start, end); + addOrReplaceSpan( + spannedText, + new TypefaceSpan(style.getFontFamily()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } Layout.Alignment textAlign = style.getTextAlign(); if (textAlign != null) { - addOrReplaceSpan(spannedText, new AlignmentSpan.Standard(textAlign), start, end); + addOrReplaceSpan( + spannedText, + new AlignmentSpan.Standard(textAlign), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } switch (style.getFontSizeUnit()) { case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL: addOrReplaceSpan( - spannedText, new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end); + spannedText, + new AbsoluteSizeSpan((int) style.getFontSize(), true), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case WebvttCssStyle.FONT_SIZE_UNIT_EM: - addOrReplaceSpan(spannedText, new RelativeSizeSpan(style.getFontSize()), start, end); + addOrReplaceSpan( + spannedText, + new RelativeSizeSpan(style.getFontSize()), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT: - addOrReplaceSpan(spannedText, new RelativeSizeSpan(style.getFontSize() / 100), start, end); + addOrReplaceSpan( + spannedText, + new RelativeSizeSpan(style.getFontSize() / 100), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case WebvttCssStyle.UNSPECIFIED: // Do nothing. @@ -578,26 +616,6 @@ public final class WebvttCueParser { } } - /** - * Adds {@code span} to {@code spannedText} between {@code start} and {@code end}, removing any - * existing spans of the same type and with the same indices. - * - *

This is useful for types of spans that don't make sense to duplicate and where the - * evaluation order might have an unexpected impact on the final text, e.g. {@link - * ForegroundColorSpan}. - */ - private static void addOrReplaceSpan( - SpannableStringBuilder spannedText, Object span, int start, int end) { - Object[] existingSpans = spannedText.getSpans(start, end, span.getClass()); - for (Object existingSpan : existingSpans) { - if (spannedText.getSpanStart(existingSpan) == start - && spannedText.getSpanEnd(existingSpan) == end) { - spannedText.removeSpan(existingSpan); - } - } - spannedText.setSpan(span, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - /** * Returns the tag name for the given tag contents. * diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/SpanUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/SpanUtilTest.java new file mode 100644 index 0000000000..3a71925255 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/SpanUtilTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.text; + +import static com.google.common.truth.Truth.assertThat; + +import android.graphics.Color; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link SpanUtil}. */ +@RunWith(AndroidJUnit4.class) +public class SpanUtilTest { + + @Test + public void addOrReplaceSpan_replacesSameTypeAndIndexes() { + Spannable spannable = SpannableString.valueOf("test text"); + spannable.setSpan( + new ForegroundColorSpan(Color.CYAN), + /* start= */ 2, + /* end= */ 5, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + ForegroundColorSpan newSpan = new ForegroundColorSpan(Color.BLUE); + SpanUtil.addOrReplaceSpan( + spannable, newSpan, /* start= */ 2, /* end= */ 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + Object[] spans = spannable.getSpans(0, spannable.length(), Object.class); + assertThat(spans).asList().containsExactly(newSpan); + } + + @Test + public void addOrReplaceSpan_ignoresDifferentType() { + Spannable spannable = SpannableString.valueOf("test text"); + ForegroundColorSpan originalSpan = new ForegroundColorSpan(Color.CYAN); + spannable.setSpan(originalSpan, /* start= */ 2, /* end= */ 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + BackgroundColorSpan newSpan = new BackgroundColorSpan(Color.BLUE); + SpanUtil.addOrReplaceSpan(spannable, newSpan, 2, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + Object[] spans = spannable.getSpans(0, spannable.length(), Object.class); + assertThat(spans).asList().containsExactly(originalSpan, newSpan).inOrder(); + } + + @Test + public void addOrReplaceSpan_ignoresDifferentStartEndAndFlags() { + Spannable spannable = SpannableString.valueOf("test text"); + ForegroundColorSpan originalSpan = new ForegroundColorSpan(Color.CYAN); + spannable.setSpan(originalSpan, /* start= */ 2, /* end= */ 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + ForegroundColorSpan differentStart = new ForegroundColorSpan(Color.GREEN); + SpanUtil.addOrReplaceSpan( + spannable, differentStart, /* start= */ 3, /* end= */ 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ForegroundColorSpan differentEnd = new ForegroundColorSpan(Color.BLUE); + SpanUtil.addOrReplaceSpan( + spannable, differentEnd, /* start= */ 2, /* end= */ 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ForegroundColorSpan differentFlags = new ForegroundColorSpan(Color.GREEN); + SpanUtil.addOrReplaceSpan( + spannable, differentFlags, /* start= */ 2, /* end= */ 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + Object[] spans = spannable.getSpans(0, spannable.length(), Object.class); + assertThat(spans) + .asList() + .containsExactly(originalSpan, differentStart, differentEnd, differentFlags) + .inOrder(); + } +}