From fcda8d47ff5606b8a36b3ec44d12946979fc0612 Mon Sep 17 00:00:00 2001 From: Denise LaFayette Date: Thu, 4 Feb 2021 11:56:30 -0800 Subject: [PATCH] Support tts:textEmphasis in TTML parser and WebView output --- .../text/span/TextEmphasisSpan.java | 134 +++++++ .../exoplayer2/text/ttml/TextEmphasis.java | 201 ++++++++++ .../exoplayer2/text/ttml/TtmlDecoder.java | 4 + .../exoplayer2/text/ttml/TtmlNode.java | 33 +- .../exoplayer2/text/ttml/TtmlRenderUtil.java | 26 +- .../exoplayer2/text/ttml/TtmlStyle.java | 15 + .../text/ttml/TextEmphasisTest.java | 369 ++++++++++++++++++ .../exoplayer2/text/ttml/TtmlDecoderTest.java | 113 +++++- .../exoplayer2/text/ttml/TtmlStyleTest.java | 23 +- .../exoplayer2/ui/SpannedToHtmlConverter.java | 53 ++- .../ui/SpannedToHtmlConverterTest.java | 32 ++ .../test/assets/media/ttml/text_emphasis.xml | 65 +++ .../testutil/truth/SpannedSubject.java | 133 +++++++ .../testutil/truth/SpannedSubjectTest.java | 100 +++++ 14 files changed, 1286 insertions(+), 15 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java create mode 100644 testdata/src/test/assets/media/ttml/text_emphasis.xml 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 new file mode 100644 index 0000000000..6d87ad521c --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 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; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +public final class TextEmphasisSpan { + /** + * Text Emphasis Position + */ + + /** + * The text emphasis position is unknown. If an implementation does not recognize or otherwise + * distinguish an annotation position value, then it must be interpreted as if a position of + * before were specified; as such, an implementation that supports text annotation marks must + * minimally support the before value. + */ + public static final int POSITION_UNKNOWN = -1; + + /** + * The emphasis marks should be positioned above the base text in horizontal writing mode The + * emphasis marks should be positioned to the right of the base text in vertical writing mode + */ + public static final int POSITION_BEFORE = 1; + + /** + * The emphasis marks should be positioned below the base text in horizontal writing mode The + * emphasis marks should be positioned to the left of the base text in vertical writing mode + */ + public static final int POSITION_AFTER = 2; + + /** + * The text emphasis should be positioned in following way: + * + * + */ + public static final int POSITION_OUTSIDE = 3; + + /** + * The possible positions of the emphasis marks relative to the base text. + * + *

One of: + * + *

+ */ + @Documented + @Retention(SOURCE) + @IntDef({POSITION_UNKNOWN, POSITION_BEFORE, POSITION_AFTER, POSITION_OUTSIDE}) + public @interface Position { + + } + + /** + * The text emphasis position is unknown. + */ + public static final int MARK_UNKNOWN = -1; + + public static final int MARK_AUTO = 1; + public static final int MARK_FILLED_CIRCLE = 2; + public static final int MARK_FILLED_DOT = 3; + public static final int MARK_FILLED_SESAME = 4; + public static final int MARK_OPEN_CIRCLE = 5; + public static final int MARK_OPEN_DOT = 6; + public static final int MARK_OPEN_SESAME = 7; + + /** + * The possible types of annotations used. + * + *

One of: + * + *

+ */ + @Documented + @Retention(SOURCE) + @IntDef({MARK_UNKNOWN, MARK_AUTO, MARK_FILLED_CIRCLE, MARK_FILLED_DOT, MARK_FILLED_SESAME, + MARK_OPEN_CIRCLE, MARK_OPEN_DOT, MARK_OPEN_SESAME}) + public @interface Mark { + + } + + /** + * The position of the text emphasis relative to the base text + */ + @TextEmphasisSpan.Position + public final int position; + + /** + * The text emphasis mark + */ + @TextEmphasisSpan.Mark + public final int mark; + + public TextEmphasisSpan(@TextEmphasisSpan.Mark int mark, + @TextEmphasisSpan.Position int position) { + this.mark = mark; + this.position = position; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java new file mode 100644 index 0000000000..6d1be2e4cb --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 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.ttml; + +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_AUTO; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_FILLED_CIRCLE; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_FILLED_DOT; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_FILLED_SESAME; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_OPEN_CIRCLE; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_OPEN_DOT; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_OPEN_SESAME; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.POSITION_AFTER; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.POSITION_BEFORE; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.POSITION_OUTSIDE; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.POSITION_UNKNOWN; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; + +/** + * This class is used to emphasize text using markers above or below the text. For example, markers + * known as boutens are commonly used in Japanese texts. Boutens are dots placed above or below a + * word or phrase that act as literal points of emphasis, equivalent to the use of italics in + * English. Boutens can help express implied meanings which provide a richer and more dynamic + * translation. + */ +/* package */ final class TextEmphasis { + + /** + * The position of the text emphasis relative to the base text. + */ + @TextEmphasisSpan.Position + public final int position; + + /** + * The desired emphasis mark + */ + @TextEmphasisSpan.Mark + public final int mark; + + private TextEmphasis(@TextEmphasisSpan.Mark int mark, @TextEmphasisSpan.Position int position) { + this.mark = mark; + this.position = position; + } + + @Override + public String toString() { + return "TextEmphasis{" + + "position=" + position + + ", mark=" + mark + + '}'; + } + + public static TextEmphasis createTextEmphasis(@Nullable String value) { + if (value == null) { + return null; + } + + String parsingValue = value.toLowerCase().trim(); + if ("".equals(parsingValue)) { + return null; + } + + String[] nodes = parsingValue.split("\\s+"); + + switch (nodes.length) { + case 0: + return null; + case 1: + return handleOneNode(nodes[0]); + case 2: + return handleTwoNodes(nodes[0], nodes[1]); + default: + // We ignore anything after third entry in value + return handleThreeNodes(nodes[0], nodes[1], nodes[2]); + } + } + + private static @Nullable + TextEmphasis handleOneNode(@NonNull String value) { + + if (TtmlNode.TEXT_EMPHASIS_NONE.equals(value)) { + return null; + } + + // Handle "auto" or unknown value + // If an implementation does not recognize or otherwise distinguish an emphasis style value, + // then it must be interpreted as if a style of auto were specified; as such, an implementation + // that supports text emphasis marks must minimally support the auto value. + return new TextEmphasis(MARK_AUTO, POSITION_UNKNOWN); + } + + private static @Nullable + TextEmphasis handleTwoNodes(@NonNull String mark, @NonNull String position) { + + @TextEmphasisSpan.Position int positionEntry = getPosition(position); + @TextEmphasisSpan.Mark int markEntry; + switch (mark) { + case TtmlNode.TEXT_EMPHASIS_AUTO: + markEntry = MARK_AUTO; + break; + // If only circle, dot, or sesame is specified, then it is equivalent to filled circle, + // filled dot, and filled sesame, respectively. + case TtmlNode.TEXT_EMPHASIS_MARK_DOT: + markEntry = MARK_FILLED_DOT; + break; + case TtmlNode.TEXT_EMPHASIS_MARK_SESAME: + markEntry = MARK_FILLED_SESAME; + break; + case TtmlNode.TEXT_EMPHASIS_MARK_CIRCLE: + markEntry = MARK_FILLED_CIRCLE; + break; + default: + // This is use case for: "filled dot" when position is not specified. + return handleWithPosition(mark, position, POSITION_UNKNOWN); + } + + return new TextEmphasis(markEntry, positionEntry); + } + + private static @Nullable + TextEmphasis handleWithPosition(@NonNull String markStyle, @Nullable String mark, + @TextEmphasisSpan.Position int position) { + + switch (mark) { + + case TtmlNode.TEXT_EMPHASIS_MARK_DOT: + if (TtmlNode.TEXT_EMPHASIS_MARK_FILLED.equals(markStyle)) { + return new TextEmphasis(MARK_FILLED_DOT, position); + } else if (TtmlNode.TEXT_EMPHASIS_MARK_OPEN.equals(markStyle)) { + return new TextEmphasis(MARK_OPEN_DOT, position); + } else { + return new TextEmphasis(MARK_FILLED_DOT, position); + } + + case TtmlNode.TEXT_EMPHASIS_MARK_SESAME: + if (TtmlNode.TEXT_EMPHASIS_MARK_FILLED.equals(markStyle)) { + return new TextEmphasis(MARK_FILLED_SESAME, position); + } else if (TtmlNode.TEXT_EMPHASIS_MARK_OPEN.equals(markStyle)) { + return new TextEmphasis(MARK_OPEN_SESAME, position); + } else { + return new TextEmphasis(MARK_FILLED_SESAME, position); + } + + case TtmlNode.TEXT_EMPHASIS_MARK_CIRCLE: + if (TtmlNode.TEXT_EMPHASIS_MARK_FILLED.equals(markStyle)) { + return new TextEmphasis(MARK_FILLED_CIRCLE, position); + } else if (TtmlNode.TEXT_EMPHASIS_MARK_OPEN.equals(markStyle)) { + return new TextEmphasis(MARK_OPEN_CIRCLE, position); + } else { + return new TextEmphasis(MARK_FILLED_CIRCLE, position); + } + + default: + // Not supported, default to AUTO. + break; + } + + return new TextEmphasis(MARK_AUTO, POSITION_UNKNOWN); + } + + private static @Nullable + TextEmphasis handleThreeNodes(@NonNull String markStyle, @NonNull String mark, + @NonNull String position) { + + @TextEmphasisSpan.Position int positionEntry = getPosition(position); + return handleWithPosition(markStyle, mark, positionEntry); + } + + private static @TextEmphasisSpan.Position + int getPosition(@NonNull String value) { + + switch (value) { + case TtmlNode.TEXT_EMPHASIS_POSITION_AFTER: + return POSITION_AFTER; + case TtmlNode.TEXT_EMPHASIS_POSITION_BEFORE: + return POSITION_BEFORE; + case TtmlNode.TEXT_EMPHASIS_POSITION_OUTSIDE: + return POSITION_OUTSIDE; + default: + // ignore + break; + } + return POSITION_UNKNOWN; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java index 611eb7ff2f..8e13ea0c8d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java @@ -609,6 +609,10 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { break; } break; + case TtmlNode.ATTR_TTS_TEXT_EMPHASIS: + style = createIfNull(style).setTextEmphasis( + TextEmphasis.createTextEmphasis(Util.toLowerInvariant(attributeValue))); + break; default: // ignore break; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java index 8e516dedf1..0a22c76575 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -69,6 +69,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public static final String ATTR_TTS_TEXT_DECORATION = "textDecoration"; public static final String ATTR_TTS_TEXT_ALIGN = "textAlign"; public static final String ATTR_TTS_TEXT_COMBINE = "textCombine"; + public static final String ATTR_TTS_TEXT_EMPHASIS = "textEmphasis"; public static final String ATTR_TTS_WRITING_MODE = "writingMode"; // Values for ruby @@ -106,6 +107,19 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public static final String VERTICAL_LR = "tblr"; public static final String VERTICAL_RL = "tbrl"; + // Values for textEmphasis + public static final String TEXT_EMPHASIS_NONE = "none"; + public static final String TEXT_EMPHASIS_AUTO = "auto"; + public static final String TEXT_EMPHASIS_MARK_DOT = "dot"; + public static final String TEXT_EMPHASIS_MARK_SESAME = "sesame"; + public static final String TEXT_EMPHASIS_MARK_CIRCLE = "circle"; + public static final String TEXT_EMPHASIS_MARK_FILLED = "filled"; + public static final String TEXT_EMPHASIS_MARK_OPEN = "open"; + + public static final String TEXT_EMPHASIS_POSITION_AFTER = "after"; + public static final String TEXT_EMPHASIS_POSITION_BEFORE = "before"; + public static final String TEXT_EMPHASIS_POSITION_OUTSIDE = "outside"; + @Nullable public final String tag; @Nullable public final String text; public final boolean isTextNode; @@ -243,7 +257,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; TreeMap regionTextOutputs = new TreeMap<>(); traverseForText(timeUs, false, regionId, regionTextOutputs); - traverseForStyle(timeUs, globalStyles, regionTextOutputs); + traverseForStyle(timeUs, globalStyles, regionMap, regionId, regionTextOutputs); List cues = new ArrayList<>(); @@ -354,26 +368,32 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } private void traverseForStyle( - long timeUs, Map globalStyles, Map regionOutputs) { + long timeUs, Map globalStyles, Map regionMaps, + String inheritedRegion, Map regionOutputs) { if (!isActive(timeUs)) { return; } + String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; + for (Map.Entry entry : nodeEndsByRegion.entrySet()) { String regionId = entry.getKey(); int start = nodeStartsByRegion.containsKey(regionId) ? nodeStartsByRegion.get(regionId) : 0; int end = entry.getValue(); if (start != end) { Cue.Builder regionOutput = Assertions.checkNotNull(regionOutputs.get(regionId)); - applyStyleToOutput(globalStyles, regionOutput, start, end); + @Cue.VerticalType int verticalType = Assertions + .checkNotNull(regionMaps.get(resolvedRegionId)).verticalType; + applyStyleToOutput(globalStyles, regionOutput, start, end, verticalType); } } for (int i = 0; i < getChildCount(); ++i) { - getChild(i).traverseForStyle(timeUs, globalStyles, regionOutputs); + getChild(i).traverseForStyle(timeUs, globalStyles, regionMaps, resolvedRegionId, regionOutputs); } } private void applyStyleToOutput( - Map globalStyles, Cue.Builder regionOutput, int start, int end) { + Map globalStyles, Cue.Builder regionOutput, int start, int end, + @Cue.VerticalType int verticalType) { @Nullable TtmlStyle resolvedStyle = TtmlRenderUtil.resolveStyle(style, styleIds, globalStyles); @Nullable SpannableStringBuilder text = (SpannableStringBuilder) regionOutput.getText(); if (text == null) { @@ -381,7 +401,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutput.setText(text); } if (resolvedStyle != null) { - TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent, globalStyles); + TtmlRenderUtil + .applyStylesToSpan(text, start, end, resolvedStyle, parent, globalStyles, verticalType); regionOutput.setTextAlignment(resolvedStyle.getTextAlign()); } } 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 13f3fe2b16..b7c03e3053 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 @@ -27,9 +27,11 @@ import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; import androidx.annotation.Nullable; +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.SpanUtil; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.util.ArrayDeque; @@ -83,7 +85,8 @@ import java.util.Map; int end, TtmlStyle style, @Nullable TtmlNode parent, - Map globalStyles) { + Map globalStyles, + @Cue.VerticalType int verticalType) { if (style.getStyle() != TtmlStyle.UNSPECIFIED) { builder.setSpan(new StyleSpan(style.getStyle()), start, end, @@ -119,6 +122,27 @@ import java.util.Map; end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } + if (style.getTextEmphasis() != null) { + TextEmphasis textEmphasis = style.getTextEmphasis(); + // https://www.w3.org/TR/ttml2/#style-value-emphasis-style + // If an implementation does not recognize or otherwise distinguish an emphasis style value, + // then it must be interpreted as if a style of auto were specified; as such, an + // implementation that supports text emphasis marks must minimally support the auto value. + // If a vertical writing mode applies, then equivalent to filled sesame; otherwise, equivalent + // to filled circle. + @TextEmphasisSpan.Mark int mark = textEmphasis.mark; + if (textEmphasis.mark == TextEmphasisSpan.MARK_AUTO + || textEmphasis.mark == TextEmphasisSpan.MARK_UNKNOWN) { + mark = (verticalType == Cue.VERTICAL_TYPE_LR || verticalType == Cue.VERTICAL_TYPE_RL) ? + TextEmphasisSpan.MARK_FILLED_SESAME : TextEmphasisSpan.MARK_FILLED_CIRCLE; + } + SpanUtil.addOrReplaceSpan( + builder, + new TextEmphasisSpan(mark, textEmphasis.position), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } switch (style.getRubyType()) { case TtmlStyle.RUBY_TYPE_BASE: // look for the sibling RUBY_TEXT and add it as span between start & end. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java index 3ca519660d..f25defbb8b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java @@ -87,6 +87,8 @@ import java.lang.annotation.RetentionPolicy; @Nullable private Layout.Alignment textAlign; @OptionalBoolean private int textCombine; + private TextEmphasis textEmphasis; + public TtmlStyle() { linethrough = UNSPECIFIED; underline = UNSPECIFIED; @@ -238,6 +240,9 @@ import java.lang.annotation.RetentionPolicy; fontSizeUnit = ancestor.fontSizeUnit; fontSize = ancestor.fontSize; } + if (textEmphasis == null) { + textEmphasis = ancestor.textEmphasis; + } // attributes not inherited as of http://www.w3.org/TR/ttml1/ if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) { setBackgroundColor(ancestor.backgroundColor); @@ -299,6 +304,16 @@ import java.lang.annotation.RetentionPolicy; return this; } + @Nullable + public TextEmphasis getTextEmphasis() { + return textEmphasis; + } + + public TtmlStyle setTextEmphasis(@Nullable TextEmphasis textEmphasis) { + this.textEmphasis = textEmphasis; + return this; + } + public TtmlStyle setFontSize(float fontSize) { this.fontSize = fontSize; return this; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java new file mode 100644 index 0000000000..6d50cdf08b --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java @@ -0,0 +1,369 @@ +package com.google.android.exoplayer2.text.ttml; + +import static com.google.android.exoplayer2.text.ttml.TextEmphasis.createTextEmphasis; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; +import com.google.android.exoplayer2.util.Log; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link TextEmphasis}. */ +@RunWith(AndroidJUnit4.class) +public class TextEmphasisTest { + + public final String TAG = "TextEmphasisTest"; + + @Test + public void testNull() { + String value = null; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must be null").that(textEmphasis).isNull(); + } + + @Test + public void testEmpty() { + String value = ""; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must be null").that(textEmphasis).isNull(); + } + + @Test + public void testNone() { + String value = "none"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must be null").that(textEmphasis).isNull(); + } + + @Test + public void testAuto() { + String value = "auto"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_AUTO); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_UNKNOWN); + } + + @Test + public void testAutoOutside() { + String value = "auto outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_AUTO); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + /** + * If only circle, dot, or sesame is specified, then it is equivalent to filled circle, filled dot, + * and filled sesame, respectively. + */ + + @Test + public void testDotBefore() { + String value = "dot before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testCircleBefore() { + String value = "circle before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testSesameBefore() { + String value = "sesame before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testDotAfter() { + String value = "dot AFTER"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testCircleAfter() { + String value = "circle after"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testSesameAfter() { + String value = "sesame aFter"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testDotOutside() { + String value = "dot outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testCircleOutside() { + String value = "circle outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testSesameOutside() { + String value = "sesame outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testOpenDotAfter() { + String value = "open dot AFTER"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testOpenCircleAfter() { + String value = "Open circle after"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testOpenSesameAfter() { + String value = "open sesame aFter"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testOpenDotBefore() { + String value = "open dot before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testOpenCircleBefore() { + String value = "Open circle Before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testOpenSesameBefore() { + String value = "open sesame Before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testOpenDotOutside() { + String value = "open dot Outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testOpenCircleOutside() { + String value = "Open circle Outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testOpenSesameOutside() { + String value = "open sesame outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_OPEN_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testFilledDotOutside() { + String value = "filled dot outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testFilledCircleOutside() { + String value = "filled circle outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testFilledSesameOutside() { + String value = "filled sesame outside"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_OUTSIDE); + } + + @Test + public void testFilledDotAfter() { + String value = "filled dot After"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testFilledCircleAfter() { + String value = "filled circle after"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testFilledSesameAfter() { + String value = "filled sesame After"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_AFTER); + } + + @Test + public void testFilledDotBefore() { + String value = "filled dot before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_DOT); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testFilledCircleBefore() { + String value = "filled circle Before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_CIRCLE); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } + + @Test + public void testFilledSesameBefore() { + String value = "filled sesame Before"; + TextEmphasis textEmphasis = createTextEmphasis(value); + + Log.d(TAG, "textEmphasis: " + textEmphasis); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertThat(textEmphasis.mark).isEqualTo(TextEmphasisSpan.MARK_FILLED_SESAME); + assertThat(textEmphasis.position).isEqualTo(TextEmphasisSpan.POSITION_BEFORE); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java index dac21f3628..fa4c28aaf1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java @@ -28,6 +28,7 @@ import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Subtitle; import com.google.android.exoplayer2.text.SubtitleDecoderException; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import java.io.IOException; @@ -36,7 +37,9 @@ import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link TtmlDecoder}. */ +/** + * Unit test for {@link TtmlDecoder}. + */ @RunWith(AndroidJUnit4.class) public final class TtmlDecoderTest { @@ -67,6 +70,7 @@ public final class TtmlDecoderTest { private static final String VERTICAL_TEXT_FILE = "media/ttml/vertical_text.xml"; private static final String TEXT_COMBINE_FILE = "media/ttml/text_combine.xml"; private static final String RUBIES_FILE = "media/ttml/rubies.xml"; + private static final String TEXT_EMPHASIS_FILE = "media/ttml/text_emphasis.xml"; @Test public void inlineAttributes() throws IOException, SubtitleDecoderException { @@ -109,12 +113,10 @@ public final class TtmlDecoderTest { * framework level. Tests that lime resolves to #FF00FF00 not #00FF00 * . * - * @see - * JellyBean Color - * Kitkat Color * @throws IOException thrown if reading subtitle file fails. + * @see + * JellyBean Color + * Kitkat Color */ @Test public void lime() throws IOException, SubtitleDecoderException { @@ -674,6 +676,105 @@ public final class TtmlDecoderTest { assertThat(sixthCue).hasNoRubySpanBetween(0, sixthCue.length()); } + @Test + public void textEmphasis() throws IOException, SubtitleDecoderException { + TtmlSubtitle subtitle = getSubtitle(TEXT_EMPHASIS_FILE); + + Spanned firstCue = getOnlyCueTextAtTimeUs(subtitle, 10_000_000); + assertThat(firstCue) + .hasNoTextEmphasisSpanBetween("None ".length(), "None おはよ".length()); + + Spanned secondCue = getOnlyCueTextAtTimeUs(subtitle, 20_000_000); + assertThat(secondCue) + .hasTextEmphasisSpanBetween("Auto ".length(), "Auto ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_CIRCLE, + TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000); + assertThat(thirdCue) + .hasTextEmphasisSpanBetween("Filled circle ".length(), "Filled circle こんばんは".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_CIRCLE, + TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned fourthCue = getOnlyCueTextAtTimeUs(subtitle, 40_000_000); + assertThat(fourthCue) + .hasTextEmphasisSpanBetween("Filled dot ".length(), "Filled dot ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_DOT, TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned fifthCue = getOnlyCueTextAtTimeUs(subtitle, 50_000_000); + assertThat(fifthCue) + .hasTextEmphasisSpanBetween("Filled sesame ".length(), "Filled sesame おはよ".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_SESAME, + TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned sixthCue = getOnlyCueTextAtTimeUs(subtitle, 60_000_000); + assertThat(sixthCue) + .hasTextEmphasisSpanBetween("Open circle before ".length(), + "Open circle before ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_OPEN_CIRCLE, TextEmphasisSpan.POSITION_BEFORE); + + Spanned seventhCue = getOnlyCueTextAtTimeUs(subtitle, 70_000_000); + assertThat(seventhCue) + .hasTextEmphasisSpanBetween("Open dot after ".length(), "Open dot after おはよ".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_OPEN_DOT, TextEmphasisSpan.POSITION_AFTER); + + Spanned eighthCue = getOnlyCueTextAtTimeUs(subtitle, 80_000_000); + assertThat(eighthCue) + .hasTextEmphasisSpanBetween("Open sesame outside ".length(), + "Open sesame outside ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_OPEN_SESAME, TextEmphasisSpan.POSITION_OUTSIDE); + + Spanned ninthCue = getOnlyCueTextAtTimeUs(subtitle, 90_000_000); + assertThat(ninthCue) + .hasTextEmphasisSpanBetween("Auto outside ".length(), "Auto outside おはよ".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_CIRCLE, + TextEmphasisSpan.POSITION_OUTSIDE); + + Spanned tenthCue = getOnlyCueTextAtTimeUs(subtitle, 100_000_000); + assertThat(tenthCue) + .hasTextEmphasisSpanBetween("Circle before ".length(), "Circle before ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_BEFORE); + + Spanned eleventhCue = getOnlyCueTextAtTimeUs(subtitle, 110_000_000); + assertThat(eleventhCue) + .hasTextEmphasisSpanBetween("Sesame after ".length(), "Sesame after おはよ".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_SESAME, TextEmphasisSpan.POSITION_AFTER); + + Spanned twelfthCue = getOnlyCueTextAtTimeUs(subtitle, 120_000_000); + assertThat(twelfthCue) + .hasTextEmphasisSpanBetween("Dot outside ".length(), "Dot outside ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_DOT, TextEmphasisSpan.POSITION_OUTSIDE); + + Spanned thirteenthCue = getOnlyCueTextAtTimeUs(subtitle, 130_000_000); + assertThat(thirteenthCue) + .hasNoTextEmphasisSpanBetween("No textEmphasis property ".length(), + "No textEmphasis property おはよ".length()); + + Spanned fourteenthCue = getOnlyCueTextAtTimeUs(subtitle, 140_000_000); + assertThat(fourteenthCue) + .hasTextEmphasisSpanBetween("Auto (TBLR) ".length(), "Auto (TBLR) ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_SESAME, + TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned fifteenthCue = getOnlyCueTextAtTimeUs(subtitle, 150_000_000); + assertThat(fifteenthCue) + .hasTextEmphasisSpanBetween("Auto (TBRL) ".length(), "Auto (TBRL) おはよ".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_SESAME, + TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned sixteenthCue = getOnlyCueTextAtTimeUs(subtitle, 160_000_000); + assertThat(sixteenthCue) + .hasTextEmphasisSpanBetween("Auto (TB) ".length(), "Auto (TB) ございます".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_SESAME, + TextEmphasisSpan.POSITION_UNKNOWN); + + Spanned seventeenthCue = getOnlyCueTextAtTimeUs(subtitle, 170_000_000); + assertThat(seventeenthCue) + .hasTextEmphasisSpanBetween("Auto (LR) ".length(), "Auto (LR) おはよ".length()) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_CIRCLE, + TextEmphasisSpan.POSITION_UNKNOWN); + } + private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) { Cue cue = getOnlyCueAtTimeUs(subtitle, timeUs); assertThat(cue.text).isInstanceOf(Spanned.class); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java index a3ad1ba599..60409315ef 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java @@ -16,6 +16,10 @@ package com.google.android.exoplayer2.text.ttml; import static android.graphics.Color.BLACK; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_FILLED_DOT; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.MARK_OPEN_SESAME; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.POSITION_AFTER; +import static com.google.android.exoplayer2.text.span.TextEmphasisSpan.POSITION_BEFORE; import static com.google.android.exoplayer2.text.ttml.TtmlStyle.STYLE_BOLD; import static com.google.android.exoplayer2.text.ttml.TtmlStyle.STYLE_BOLD_ITALIC; import static com.google.android.exoplayer2.text.ttml.TtmlStyle.STYLE_ITALIC; @@ -46,6 +50,7 @@ public final class TtmlStyleTest { private static final int RUBY_POSITION = RubySpan.POSITION_UNDER; private static final Layout.Alignment TEXT_ALIGN = Layout.Alignment.ALIGN_CENTER; private static final boolean TEXT_COMBINE = true; + public static final String TEXT_EMPHASIS_STYLE="dot before"; private final TtmlStyle populatedStyle = new TtmlStyle() @@ -62,7 +67,8 @@ public final class TtmlStyleTest { .setRubyType(RUBY_TYPE) .setRubyPosition(RUBY_POSITION) .setTextAlign(TEXT_ALIGN) - .setTextCombine(TEXT_COMBINE); + .setTextCombine(TEXT_COMBINE) + .setTextEmphasis(TextEmphasis.createTextEmphasis(TEXT_EMPHASIS_STYLE)); @Test public void inheritStyle() { @@ -86,6 +92,9 @@ public final class TtmlStyleTest { assertWithMessage("backgroundColor should not be inherited") .that(style.hasBackgroundColor()) .isFalse(); + assertThat(style.getTextEmphasis()).isNotNull(); + assertThat(style.getTextEmphasis().mark).isEqualTo(MARK_FILLED_DOT); + assertThat(style.getTextEmphasis().position).isEqualTo(POSITION_BEFORE); } @Test @@ -109,6 +118,9 @@ public final class TtmlStyleTest { .that(style.getBackgroundColor()) .isEqualTo(BACKGROUND_COLOR); assertWithMessage("rubyType should be chained").that(style.getRubyType()).isEqualTo(RUBY_TYPE); + assertThat(style.getTextEmphasis()).isNotNull(); + assertThat(style.getTextEmphasis().mark).isEqualTo(MARK_FILLED_DOT); + assertThat(style.getTextEmphasis().position).isEqualTo(POSITION_BEFORE); } @Test @@ -245,4 +257,13 @@ public final class TtmlStyleTest { style.setTextCombine(true); assertThat(style.getTextCombine()).isTrue(); } + + @Test + public void textEmphasis() { + TtmlStyle style = new TtmlStyle(); + assertThat(style.getTextEmphasis()).isNull(); + style.setTextEmphasis(TextEmphasis.createTextEmphasis("open sesame after")); + assertThat(style.getTextEmphasis().mark).isEqualTo(MARK_OPEN_SESAME); + assertThat(style.getTextEmphasis().position).isEqualTo(POSITION_AFTER); + } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java index 7ea2b55cf4..95c6562599 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java @@ -31,6 +31,7 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableMap; @@ -197,6 +198,14 @@ import java.util.regex.Pattern; } } else if (span instanceof UnderlineSpan) { return ""; + } else if (span instanceof TextEmphasisSpan) { + TextEmphasisSpan textEmphasisSpan = (TextEmphasisSpan) span; + String style = getTextEmphasisStyle(textEmphasisSpan.mark); + String position = getTextEmphasisPosition(textEmphasisSpan.position); + return Util + .formatInvariant("", + style, style, position, position); } else { return null; } @@ -209,7 +218,8 @@ import java.util.regex.Pattern; || span instanceof BackgroundColorSpan || span instanceof HorizontalTextInVerticalContextSpan || span instanceof AbsoluteSizeSpan - || span instanceof RelativeSizeSpan) { + || span instanceof RelativeSizeSpan + || span instanceof TextEmphasisSpan) { return ""; } else if (span instanceof TypefaceSpan) { @Nullable String fontFamily = ((TypefaceSpan) span).getFamily(); @@ -232,6 +242,47 @@ import java.util.regex.Pattern; return null; } + private static String getTextEmphasisStyle(@TextEmphasisSpan.Mark int mark) { + switch (mark) { + case TextEmphasisSpan.MARK_FILLED_CIRCLE: + return "filled circle"; + case TextEmphasisSpan.MARK_FILLED_DOT: + return "filled dot"; + case TextEmphasisSpan.MARK_FILLED_SESAME: + return "filled sesame"; + case TextEmphasisSpan.MARK_OPEN_CIRCLE: + return "open circle"; + case TextEmphasisSpan.MARK_OPEN_DOT: + return "open dot"; + case TextEmphasisSpan.MARK_OPEN_SESAME: + return "open sesame"; + case TextEmphasisSpan.MARK_AUTO: // TODO + // https://www.w3.org/TR/ttml2/#style-value-emphasis-style + // If a vertical writing mode applies, then equivalent to filled sesame; otherwise, + // equivalent to filled circle. + case TextEmphasisSpan.MARK_UNKNOWN: + default: + return "unset"; + } + } + + private static String getTextEmphasisPosition(@TextEmphasisSpan.Position int position){ + switch (position) { + case TextEmphasisSpan.POSITION_AFTER: + return "under left"; + case TextEmphasisSpan.POSITION_UNKNOWN: + case TextEmphasisSpan.POSITION_BEFORE: + case TextEmphasisSpan.POSITION_OUTSIDE: /* Not supported, fallback to "before" */ + default: + // https://www.w3.org/TR/ttml2/#style-value-annotation-position + // If an implementation does not recognize or otherwise distinguish an annotation position + // value, then it must be interpreted as if a position of before were specified; as such, + // an implementation that supports text annotation marks must minimally support the before + // value. + return "over right"; + } + } + private static Transition getOrCreate(SparseArray transitions, int key) { @Nullable Transition transition = transitions.get(key); if (transition == null) { diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java index b9eb6d8e6a..b5d60416ff 100644 --- a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java @@ -34,6 +34,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; @@ -279,6 +280,37 @@ public class SpannedToHtmlConverterTest { + "section"); } + @Test + public void convert_supportsTextEmphasisSpan() { + SpannableString spanned = new SpannableString("Text emphasis おはよ ございます "); + spanned.setSpan( + new TextEmphasisSpan(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_BEFORE), + "Text emphasis ".length(), + "Text emphasis おはよ".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new TextEmphasisSpan(TextEmphasisSpan.MARK_OPEN_SESAME, TextEmphasisSpan.POSITION_AFTER), + "Text emphasis おはよ ".length(), + "Text emphasis おはよ ございます ".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); + + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo( + "Text emphasis おはよ " + + "ございます "); + } + @Test public void convert_supportsUnderlineSpan() { SpannableString spanned = new SpannableString("String with underlined section."); diff --git a/testdata/src/test/assets/media/ttml/text_emphasis.xml b/testdata/src/test/assets/media/ttml/text_emphasis.xml new file mode 100644 index 0000000000..90e85b931a --- /dev/null +++ b/testdata/src/test/assets/media/ttml/text_emphasis.xml @@ -0,0 +1,65 @@ + + + + + + + + +
+

None おはよ

+
+
+

Auto ございます

+
+
+

Filled circle こんばんは

+
+
+

Filled dot ございます

+
+
+

Filled sesame おはよ

+
+
+

Open circle before ございます

+
+
+

Open dot after おはよ

+
+
+

Open sesame outside ございます

+
+
+

Auto outside おはよ

+
+
+

Circle before ございます

+
+
+

Sesame after おはよ

+
+
+

Dot outside ございます

+
+
+

No textEmphasis property おはよ

+
+
+

Auto (TBLR) ございます

+
+
+

Auto (TBRL) おはよ

+
+
+

Auto (TB) ございます

+
+
+

Auto (LR) おはよ

+
+ + +
diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java index a980254277..546c417394 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java @@ -38,6 +38,7 @@ import androidx.annotation.ColorInt; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Util; import com.google.common.truth.Fact; import com.google.common.truth.FailureMetadata; @@ -578,6 +579,47 @@ public final class SpannedSubject extends Subject { return ALREADY_FAILED_WITH_FLAGS; } + /** + * Checks that the subject has an {@link TextEmphasisSpan} from {@code start} + * to {@code end}. + * + * @param start The start of the expected span. + * @param end The end of the expected span. + * @return A {@link WithSpanFlags} object for optional additional assertions on the flags. + */ + public TextEmphasisDescription hasTextEmphasisSpanBetween(int start, int end) { + if (actual == null) { + failWithoutActual(simpleFact("Spanned must not be null")); + return ALREADY_FAILED_WITH_MARK; + } + + List textEmphasisSpans = + findMatchingSpans(start, end, TextEmphasisSpan.class); + if (textEmphasisSpans.size() == 1) { + return check("TextEmphasisSpan (start=%s,end=%s)", start, end).about(textEmphasisSubjects(actual)).that(textEmphasisSpans); + } + failWithExpectedSpan( + start, + end, + TextEmphasisSpan.class, + actual.toString().substring(start, end)); + return ALREADY_FAILED_WITH_MARK; + } + + /** + * Checks that the subject has no {@link TextEmphasisSpan}s on any of the text between + * {@code start} and {@code end}. + * + *

This fails even if the start and end indexes don't exactly match. + * + * @param start The start index to start searching for spans. + * @param end The end index to stop searching for spans. + */ + public void hasNoTextEmphasisSpanBetween(int start, int end) { + hasNoSpansOfTypeBetween(TextEmphasisSpan.class, start, end); + } + + /** * Checks that the subject has no {@link HorizontalTextInVerticalContextSpan}s on any of the text * between {@code start} and {@code end}. @@ -1110,4 +1152,95 @@ public final class SpannedSubject extends Subject { } } } + + /** Allows assertions about a span's textEmphasis mark and its position. */ + public interface TextEmphasisDescription { + + /** + * Checks that at least one of the matched spans has the expected {@code mark} and {@code position}. + * + * @param mark The expected mark + * @param position The expected position of the mark + * @return A {@link WithSpanFlags} object for optional additional assertions on the flags. + */ + AndSpanFlags withMarkAndPosition(@TextEmphasisSpan.Mark int mark, + @TextEmphasisSpan.Position int position); + } + + private static final TextEmphasisDescription ALREADY_FAILED_WITH_MARK = + (mark, position) -> ALREADY_FAILED_AND_FLAGS; + + private static Factory> textEmphasisSubjects(Spanned actualSpanned) { + return (FailureMetadata metadata, List spans) -> + new TextEmphasisSubject(metadata, spans, actualSpanned); + } + + private static final class TextEmphasisSubject extends Subject implements TextEmphasisDescription { + + private final List actualSpans; + private final Spanned actualSpanned; + + private TextEmphasisSubject( + FailureMetadata metadata, List actualSpans, Spanned actualSpanned) { + super(metadata, actualSpans); + this.actualSpans = actualSpans; + this.actualSpanned = actualSpanned; + } + + @Override + public AndSpanFlags withMarkAndPosition(@TextEmphasisSpan.Mark int mark, + @TextEmphasisSpan.Position int position) { + List matchingSpanFlags = new ArrayList<>(); + List textEmphasisMarksAndPositions = new ArrayList<>(); + for (TextEmphasisSpan span : actualSpans) { + textEmphasisMarksAndPositions.add(new MarkAndPosition(span.mark, span.position)); + if (span.mark == mark && span.position == position) { + matchingSpanFlags.add(actualSpanned.getSpanFlags(span)); + } + } + check("textEmphasisMarkAndPosition") + .that(textEmphasisMarksAndPositions) + .containsExactly(new MarkAndPosition(mark, position)); + return check("flags").about(spanFlags()).that(matchingSpanFlags); + } + + private static final class MarkAndPosition { + + @TextEmphasisSpan.Mark + private final int mark; + @TextEmphasisSpan.Position + private final int position; + + private MarkAndPosition(@TextEmphasisSpan.Mark int mark, + @TextEmphasisSpan.Position int position) { + this.mark = mark; + this.position = position; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + TextEmphasisSubject.MarkAndPosition that = (TextEmphasisSubject.MarkAndPosition) o; + return (position == that.position) && (mark == that.mark); + } + + @Override + public int hashCode() { + int result = 34613 * mark + position; + return result; + } + + @Override + public String toString() { + return String.format("{mark=%s,position=%s}", mark, position); + } + } + } + } diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java index 75495a4293..23ee8d3dbf 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java @@ -41,6 +41,8 @@ import com.google.android.exoplayer2.testutil.truth.SpannedSubject.AndSpanFlags; import com.google.android.exoplayer2.testutil.truth.SpannedSubject.WithSpanFlags; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; +import com.google.android.exoplayer2.util.Util; import com.google.common.truth.ExpectFailure; import org.junit.Test; import org.junit.runner.RunWith; @@ -679,6 +681,104 @@ public class SpannedSubjectTest { new RubySpan("ruby text", RubySpan.POSITION_OVER), SpannedSubject::hasNoRubySpanBetween); } + @Test + public void textEmphasis_success() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_AFTER)); + + assertThat(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_AFTER) + .andFlags(Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + @Test + public void textEmphasis_wrongIndex() { + checkHasSpanFailsDueToIndexMismatch( + new TextEmphasisSpan(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_AFTER), + SpannedSubject::hasTextEmphasisSpanBetween); + } + + + @Test + public void textEmphasis_wrongMark() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_AFTER)); + + AssertionError expected = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition(TextEmphasisSpan.MARK_OPEN_DOT, + TextEmphasisSpan.POSITION_AFTER)); + + assertThat(expected).factValue("value of").contains("textEmphasisMarkAndPosition"); + assertThat(expected).factValue("expected").contains(Util.formatInvariant( + "{mark=%d,position=%d}", TextEmphasisSpan.MARK_OPEN_DOT, + TextEmphasisSpan.POSITION_AFTER)); + assertThat(expected).factValue("but was").contains(Util.formatInvariant( + "{mark=%d,position=%d}", TextEmphasisSpan.MARK_FILLED_CIRCLE, + TextEmphasisSpan.POSITION_AFTER)); + + } + + @Test + public void textEmphasis_wrongPosition() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan(TextEmphasisSpan.MARK_OPEN_SESAME, TextEmphasisSpan.POSITION_BEFORE)); + + AssertionError expected = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition(TextEmphasisSpan.MARK_OPEN_SESAME, + TextEmphasisSpan.POSITION_AFTER)); + + assertThat(expected).factValue("value of").contains("textEmphasisMarkAndPosition"); + assertThat(expected).factValue("expected").contains(Util.formatInvariant( + "{mark=%d,position=%d}", TextEmphasisSpan.MARK_OPEN_SESAME, + TextEmphasisSpan.POSITION_AFTER)); + assertThat(expected).factValue("but was").contains(Util.formatInvariant( + "{mark=%d,position=%d}", TextEmphasisSpan.MARK_OPEN_SESAME, + TextEmphasisSpan.POSITION_BEFORE)); + } + + @Test + public void textEmphasis_wrongFlags() { + checkHasSpanFailsDueToFlagMismatch( + new TextEmphasisSpan(TextEmphasisSpan.MARK_OPEN_SESAME, TextEmphasisSpan.POSITION_BEFORE), + (subject, start, end) -> + subject + .hasTextEmphasisSpanBetween(start, end) + .withMarkAndPosition(TextEmphasisSpan.MARK_OPEN_SESAME, + TextEmphasisSpan.POSITION_BEFORE)); + } + + + @Test + public void noTextEmphasis_success() { + SpannableString spannable = + createSpannableWithUnrelatedSpanAnd( + new TextEmphasisSpan(TextEmphasisSpan.MARK_FILLED_CIRCLE, + TextEmphasisSpan.POSITION_AFTER)); + + assertThat(spannable).hasNoTextEmphasisSpanBetween(UNRELATED_SPAN_START, UNRELATED_SPAN_END); + } + + @Test + public void noTextEmphasis_failure() { + checkHasNoSpanFails( + new TextEmphasisSpan(TextEmphasisSpan.MARK_FILLED_CIRCLE, TextEmphasisSpan.POSITION_AFTER), + SpannedSubject::hasNoTextEmphasisSpanBetween); + } + @Test public void horizontalTextInVerticalContextSpan_success() { SpannableString spannable =