diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cd7333e2f3..d6557f7694 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -15,6 +15,10 @@ * DRM: * Don't restore offline keys before releasing them. In OEMCrypto v16+ keys must be released without restoring them first. +* UI: + * Keep subtitle language features embedded (e.g. rubies & tate-chu-yoko) + in `Cue.text` even when `SubtitleView#setApplyEmbeddedStyles()` is + false. ### 2.14.0 (2021-05-13) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/HorizontalTextInVerticalContextSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/HorizontalTextInVerticalContextSpan.java index 587e1647c6..85dd5aad9e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/span/HorizontalTextInVerticalContextSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/HorizontalTextInVerticalContextSpan.java @@ -29,4 +29,4 @@ package com.google.android.exoplayer2.text.span; // NOTE: There's no Android layout support for this, so this span currently doesn't extend any // styling superclasses (e.g. MetricAffectingSpan). The only way to render this styling is to // extract the spans and do the layout manually. -public final class HorizontalTextInVerticalContextSpan {} +public final class HorizontalTextInVerticalContextSpan implements LanguageFeatureSpan {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/LanguageFeatureSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/LanguageFeatureSpan.java new file mode 100644 index 0000000000..704eb000d8 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/LanguageFeatureSpan.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021 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.span; + +/** Marker interface for span classes that carry language features rather than style information. */ +public interface LanguageFeatureSpan {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java index b7fb4c2d61..b7df92940d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java @@ -30,7 +30,7 @@ package com.google.android.exoplayer2.text.span; // extract the spans and do the layout manually. // TODO: Consider adding support for parenthetical text to be used when rendering doesn't support // rubies (e.g. HTML tag). -public final class RubySpan { +public final class RubySpan implements LanguageFeatureSpan { /** The ruby text, i.e. the smaller explanatory characters. */ public final String rubyText; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java index 87f37ec2d0..ce79cf6134 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java @@ -32,7 +32,7 @@ import java.lang.annotation.Retention; // NOTE: There's no Android layout support for text emphasis, so this span currently doesn't extend // any styling superclasses (e.g. MetricAffectingSpan). The only way to render this emphasis is to // extract the spans and do the layout manually. -public final class TextEmphasisSpan { +public final class TextEmphasisSpan implements LanguageFeatureSpan { /** * The possible mark shapes that can be used. diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index 62321e2219..ace00fb983 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -21,10 +21,6 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.style.AbsoluteSizeSpan; -import android.text.style.RelativeSizeSpan; import android.util.AttributeSet; import android.util.TypedValue; import android.view.View; @@ -380,37 +376,13 @@ public final class SubtitleView extends FrameLayout implements TextOutput { } private Cue removeEmbeddedStyling(Cue cue) { - @Nullable CharSequence cueText = cue.text; + Cue.Builder strippedCue = cue.buildUpon(); if (!applyEmbeddedStyles) { - Cue.Builder strippedCue = - cue.buildUpon().setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET).clearWindowColor(); - if (cueText != null) { - // Remove all spans, regardless of type. - strippedCue.setText(cueText.toString()); - } - return strippedCue.build(); + SubtitleViewUtils.removeAllEmbeddedStyling(strippedCue); } else if (!applyEmbeddedFontSizes) { - if (cueText == null) { - return cue; - } - Cue.Builder strippedCue = cue.buildUpon().setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET); - if (cueText instanceof Spanned) { - SpannableString spannable = SpannableString.valueOf(cueText); - AbsoluteSizeSpan[] absSpans = - spannable.getSpans(0, spannable.length(), AbsoluteSizeSpan.class); - for (AbsoluteSizeSpan absSpan : absSpans) { - spannable.removeSpan(absSpan); - } - RelativeSizeSpan[] relSpans = - spannable.getSpans(0, spannable.length(), RelativeSizeSpan.class); - for (RelativeSizeSpan relSpan : relSpans) { - spannable.removeSpan(relSpan); - } - strippedCue.setText(spannable); - } - return strippedCue.build(); + SubtitleViewUtils.removeEmbeddedFontSizes(strippedCue); } - return cue; + return strippedCue.build(); } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java index 24b5e30b2e..9812cafa26 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java @@ -16,7 +16,16 @@ */ package com.google.android.exoplayer2.ui; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.RelativeSizeSpan; import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.span.LanguageFeatureSpan; +import com.google.common.base.Predicate; /** Utility class for subtitle layout logic. */ /* package */ final class SubtitleViewUtils { @@ -48,5 +57,50 @@ import com.google.android.exoplayer2.text.Cue; } } + /** Removes all styling information from {@code cue}. */ + public static void removeAllEmbeddedStyling(Cue.Builder cue) { + cue.clearWindowColor(); + if (cue.getText() instanceof Spanned) { + if (!(cue.getText() instanceof Spannable)) { + cue.setText(SpannableString.valueOf(cue.getText())); + } + removeSpansIf( + (Spannable) checkNotNull(cue.getText()), span -> !(span instanceof LanguageFeatureSpan)); + } + removeEmbeddedFontSizes(cue); + } + + /** + * Removes all font size information from {@code cue}. + * + *

This involves: + * + *

+ */ + public static void removeEmbeddedFontSizes(Cue.Builder cue) { + cue.setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET); + if (cue.getText() instanceof Spanned) { + if (!(cue.getText() instanceof Spannable)) { + cue.setText(SpannableString.valueOf(cue.getText())); + } + removeSpansIf( + (Spannable) checkNotNull(cue.getText()), + span -> span instanceof AbsoluteSizeSpan || span instanceof RelativeSizeSpan); + } + } + + private static void removeSpansIf(Spannable spannable, Predicate removeFilter) { + Object[] spans = spannable.getSpans(0, spannable.length(), Object.class); + for (Object span : spans) { + if (removeFilter.apply(span)) { + spannable.removeSpan(span); + } + } + } + private SubtitleViewUtils() {} } diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SubtitleViewUtilsTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SubtitleViewUtilsTest.java new file mode 100644 index 0000000000..1ad530cc11 --- /dev/null +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SubtitleViewUtilsTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2021 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.ui; + +import static com.google.android.exoplayer2.testutil.truth.SpannedSubject.assertThat; +import static com.google.common.truth.Truth.assertThat; + +import android.graphics.Color; +import android.text.Layout; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.UnderlineSpan; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; +import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link SubtitleView}. */ +@RunWith(AndroidJUnit4.class) +public class SubtitleViewUtilsTest { + + private static final Cue CUE = buildCue(); + + @Test + public void testRemoveAllEmbeddedStyling() { + Cue.Builder cueBuilder = CUE.buildUpon(); + SubtitleViewUtils.removeAllEmbeddedStyling(cueBuilder); + Cue strippedCue = cueBuilder.build(); + + Spanned originalText = (Spanned) CUE.text; + Spanned strippedText = (Spanned) strippedCue.text; + + // Assert all non styling properties and spans are kept + assertThat(strippedCue.textAlignment).isEqualTo(CUE.textAlignment); + assertThat(strippedCue.multiRowAlignment).isEqualTo(CUE.multiRowAlignment); + assertThat(strippedCue.line).isEqualTo(CUE.line); + assertThat(strippedCue.lineType).isEqualTo(CUE.lineType); + assertThat(strippedCue.position).isEqualTo(CUE.position); + assertThat(strippedCue.positionAnchor).isEqualTo(CUE.positionAnchor); + assertThat(strippedCue.textSize).isEqualTo(Cue.DIMEN_UNSET); + assertThat(strippedCue.textSizeType).isEqualTo(Cue.TYPE_UNSET); + assertThat(strippedCue.size).isEqualTo(CUE.size); + assertThat(strippedCue.verticalType).isEqualTo(CUE.verticalType); + assertThat(strippedCue.shearDegrees).isEqualTo(CUE.shearDegrees); + TextEmphasisSpan expectedTextEmphasisSpan = + originalText.getSpans(0, originalText.length(), TextEmphasisSpan.class)[0]; + assertThat(strippedText) + .hasTextEmphasisSpanBetween( + originalText.getSpanStart(expectedTextEmphasisSpan), + originalText.getSpanEnd(expectedTextEmphasisSpan)); + RubySpan expectedRubySpan = originalText.getSpans(0, originalText.length(), RubySpan.class)[0]; + assertThat(strippedText) + .hasRubySpanBetween( + originalText.getSpanStart(expectedRubySpan), originalText.getSpanEnd(expectedRubySpan)); + HorizontalTextInVerticalContextSpan expectedHorizontalTextInVerticalContextSpan = + originalText + .getSpans(0, originalText.length(), HorizontalTextInVerticalContextSpan.class)[0]; + assertThat(strippedText) + .hasHorizontalTextInVerticalContextSpanBetween( + originalText.getSpanStart(expectedHorizontalTextInVerticalContextSpan), + originalText.getSpanEnd(expectedHorizontalTextInVerticalContextSpan)); + + // Assert all styling properties and spans are removed + assertThat(strippedCue.windowColorSet).isFalse(); + assertThat(strippedText).hasNoUnderlineSpanBetween(0, strippedText.length()); + assertThat(strippedText).hasNoRelativeSizeSpanBetween(0, strippedText.length()); + assertThat(strippedText).hasNoAbsoluteSizeSpanBetween(0, strippedText.length()); + } + + @Test + public void testRemoveEmbeddedFontSizes() { + Cue.Builder cueBuilder = CUE.buildUpon(); + SubtitleViewUtils.removeEmbeddedFontSizes(cueBuilder); + Cue strippedCue = cueBuilder.build(); + + Spanned originalText = (Spanned) CUE.text; + Spanned strippedText = (Spanned) strippedCue.text; + + // Assert all non text-size properties and spans are kept + assertThat(strippedCue.textAlignment).isEqualTo(CUE.textAlignment); + assertThat(strippedCue.multiRowAlignment).isEqualTo(CUE.multiRowAlignment); + assertThat(strippedCue.line).isEqualTo(CUE.line); + assertThat(strippedCue.lineType).isEqualTo(CUE.lineType); + assertThat(strippedCue.position).isEqualTo(CUE.position); + assertThat(strippedCue.positionAnchor).isEqualTo(CUE.positionAnchor); + assertThat(strippedCue.size).isEqualTo(CUE.size); + assertThat(strippedCue.windowColor).isEqualTo(CUE.windowColor); + assertThat(strippedCue.windowColorSet).isEqualTo(CUE.windowColorSet); + assertThat(strippedCue.verticalType).isEqualTo(CUE.verticalType); + assertThat(strippedCue.shearDegrees).isEqualTo(CUE.shearDegrees); + TextEmphasisSpan expectedTextEmphasisSpan = + originalText.getSpans(0, originalText.length(), TextEmphasisSpan.class)[0]; + assertThat(strippedText) + .hasTextEmphasisSpanBetween( + originalText.getSpanStart(expectedTextEmphasisSpan), + originalText.getSpanEnd(expectedTextEmphasisSpan)); + RubySpan expectedRubySpan = originalText.getSpans(0, originalText.length(), RubySpan.class)[0]; + assertThat(strippedText) + .hasRubySpanBetween( + originalText.getSpanStart(expectedRubySpan), originalText.getSpanEnd(expectedRubySpan)); + HorizontalTextInVerticalContextSpan expectedHorizontalTextInVerticalContextSpan = + originalText + .getSpans(0, originalText.length(), HorizontalTextInVerticalContextSpan.class)[0]; + assertThat(strippedText) + .hasHorizontalTextInVerticalContextSpanBetween( + originalText.getSpanStart(expectedHorizontalTextInVerticalContextSpan), + originalText.getSpanEnd(expectedHorizontalTextInVerticalContextSpan)); + UnderlineSpan expectedUnderlineSpan = + originalText.getSpans(0, originalText.length(), UnderlineSpan.class)[0]; + assertThat(strippedText) + .hasUnderlineSpanBetween( + originalText.getSpanStart(expectedUnderlineSpan), + originalText.getSpanEnd(expectedUnderlineSpan)); + + // Assert the text-size properties and spans are removed + assertThat(strippedCue.textSize).isEqualTo(Cue.DIMEN_UNSET); + assertThat(strippedCue.textSizeType).isEqualTo(Cue.TYPE_UNSET); + assertThat(strippedText).hasNoRelativeSizeSpanBetween(0, strippedText.length()); + assertThat(strippedText).hasNoAbsoluteSizeSpanBetween(0, strippedText.length()); + } + + private static Cue buildCue() { + SpannableString spanned = + new SpannableString("TextEmphasis おはよ Ruby ございます 123 Underline RelativeSize AbsoluteSize"); + spanned.setSpan( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE), + "Text emphasis ".length(), + "Text emphasis おはよ".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new RubySpan("おはよ", TextAnnotation.POSITION_BEFORE), + "TextEmphasis おはよ Ruby ".length(), + "TextEmphasis おはよ Ruby ございます".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new HorizontalTextInVerticalContextSpan(), + "TextEmphasis おはよ Ruby ございます ".length(), + "TextEmphasis おはよ Ruby ございます 123".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new UnderlineSpan(), + "TextEmphasis おはよ Ruby ございます 123 ".length(), + "TextEmphasis おはよ Ruby ございます 123 Underline".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new RelativeSizeSpan(1f), + "TextEmphasis おはよ Ruby ございます 123 Underline ".length(), + "TextEmphasis おはよ Ruby ございます 123 Underline RelativeSize".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new AbsoluteSizeSpan(10), + "TextEmphasis おはよ Ruby ございます 123 Underline RelativeSize ".length(), + "TextEmphasis おはよ Ruby ございます 123 Underline RelativeSize AbsoluteSize".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return new Cue.Builder() + .setText(spanned) + .setTextAlignment(Layout.Alignment.ALIGN_CENTER) + .setMultiRowAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLine(5, Cue.LINE_TYPE_NUMBER) + .setLineAnchor(Cue.ANCHOR_TYPE_END) + .setPosition(0.4f) + .setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + .setTextSize(0.2f, Cue.TEXT_SIZE_TYPE_FRACTIONAL) + .setSize(0.8f) + .setWindowColor(Color.CYAN) + .setVerticalType(Cue.VERTICAL_TYPE_RL) + .setShearDegrees(-15f) + .build(); + } +}