diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java index d87f88ce75..53bd317408 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.text.webvtt; import android.text.TextUtils; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -39,6 +40,9 @@ import java.util.regex.Pattern; private static final String PROPERTY_COLOR = "color"; private static final String PROPERTY_FONT_FAMILY = "font-family"; private static final String PROPERTY_FONT_WEIGHT = "font-weight"; + private static final String PROPERTY_RUBY_POSITION = "ruby-position"; + private static final String VALUE_OVER = "over"; + private static final String VALUE_UNDER = "under"; private static final String PROPERTY_TEXT_COMBINE_UPRIGHT = "text-combine-upright"; private static final String VALUE_ALL = "all"; private static final String VALUE_DIGITS = "digits"; @@ -186,6 +190,12 @@ import java.util.regex.Pattern; // At this point we have a presumably valid declaration, we need to parse it and fill the style. if (PROPERTY_COLOR.equals(property)) { style.setFontColor(ColorParser.parseCssColor(value)); + } else if (PROPERTY_RUBY_POSITION.equals(property)) { + if (VALUE_OVER.equals(value)) { + style.setRubyPosition(RubySpan.POSITION_OVER); + } else if (VALUE_UNDER.equals(value)) { + style.setRubyPosition(RubySpan.POSITION_UNDER); + } } else if (PROPERTY_TEXT_COMBINE_UPRIGHT.equals(property)) { style.setCombineUpright(VALUE_ALL.equals(value) || value.startsWith(VALUE_DIGITS)); } else if (PROPERTY_TEXT_DECORATION.equals(property)) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java index 41b0ba650f..bcb7e0b87c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java @@ -17,8 +17,10 @@ package com.google.android.exoplayer2.text.webvtt; import android.graphics.Typeface; import android.text.TextUtils; +import androidx.annotation.ColorInt; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -83,7 +85,7 @@ public final class WebvttCssStyle { // Style properties. @Nullable private String fontFamily; - private int fontColor; + @ColorInt private int fontColor; private boolean hasFontColor; @OptionalBoolean private int linethrough; @OptionalBoolean private int underline; @@ -91,6 +93,7 @@ public final class WebvttCssStyle { @OptionalBoolean private int italic; @FontSizeUnit private int fontSizeUnit; private float fontSize; + @RubySpan.Position private int rubyPosition; private boolean combineUpright; // Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed @@ -113,6 +116,7 @@ public final class WebvttCssStyle { bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; + rubyPosition = RubySpan.POSITION_UNKNOWN; combineUpright = false; } @@ -256,8 +260,19 @@ public final class WebvttCssStyle { return fontSize; } - public void setCombineUpright(boolean enabled) { + public WebvttCssStyle setRubyPosition(@RubySpan.Position int rubyPosition) { + this.rubyPosition = rubyPosition; + return this; + } + + @RubySpan.Position + public int getRubyPosition() { + return rubyPosition; + } + + public WebvttCssStyle setCombineUpright(boolean enabled) { this.combineUpright = enabled; + return this; } public boolean getCombineUpright() { 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 bded70e981..b26598424f 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 @@ -538,6 +538,9 @@ public final class WebvttCueParser { List scratchStyleMatches) { int start = startTag.position; int end = text.length(); + scratchStyleMatches.clear(); + getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); + switch(startTag.name) { case TAG_BOLD: text.setSpan(new StyleSpan(STYLE_BOLD), start, end, @@ -548,7 +551,7 @@ public final class WebvttCueParser { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_RUBY: - applyRubySpans(nestedElements, text, start); + applyRubySpans(text, start, nestedElements, scratchStyleMatches); break; case TAG_UNDERLINE: text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -563,16 +566,25 @@ public final class WebvttCueParser { default: return; } - scratchStyleMatches.clear(); - getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); - int styleMatchesCount = scratchStyleMatches.size(); - for (int i = 0; i < styleMatchesCount; i++) { + + for (int i = 0; i < scratchStyleMatches.size(); i++) { applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); } } private static void applyRubySpans( - List nestedElements, SpannableStringBuilder text, int startTagPosition) { + SpannableStringBuilder text, + int startTagPosition, + List nestedElements, + List styleMatches) { + @RubySpan.Position int rubyPosition = RubySpan.POSITION_OVER; + for (int i = 0; i < styleMatches.size(); i++) { + WebvttCssStyle style = styleMatches.get(i).style; + if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { + rubyPosition = style.getRubyPosition(); + break; + } + } List sortedNestedElements = new ArrayList<>(nestedElements.size()); sortedNestedElements.addAll(nestedElements); Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC); @@ -589,7 +601,7 @@ public final class WebvttCueParser { CharSequence rubyText = text.subSequence(adjustedRubyTextStart, adjustedRubyTextEnd); text.delete(adjustedRubyTextStart, adjustedRubyTextEnd); text.setSpan( - new RubySpan(rubyText.toString(), RubySpan.POSITION_OVER), + new RubySpan(rubyText.toString(), rubyPosition), lastRubyTextEnd, adjustedRubyTextStart, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -877,7 +889,7 @@ public final class WebvttCueParser { @Override public int compareTo(StyleMatch another) { - return this.score - another.score; + return Integer.compare(this.score, another.score); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java index f500029885..778820b451 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java @@ -21,7 +21,6 @@ import static com.google.common.truth.Truth.assertThat; import android.graphics.Color; import android.text.Spanned; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.text.span.RubySpan; import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -50,59 +49,6 @@ public final class WebvttCueParserTest { assertThat(text).hasNoSpans(); } - @Test - public void parseRubyTag() throws Exception { - Spanned text = - parseCueText("Some base textwith ruby and undecorated text"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("Some base text and undecorated text"); - assertThat(text) - .hasRubySpanBetween("Some ".length(), "Some base text".length()) - .withTextAndPosition("with ruby", RubySpan.POSITION_OVER); - } - - @Test - public void parseSingleRubyTagWithMultipleRts() throws Exception { - Spanned text = parseCueText("A1B2C3"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("ABC"); - assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER); - } - - @Test - public void parseMultipleRubyTagsWithSingleRtEach() throws Exception { - Spanned text = - parseCueText("A1B2C3"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("ABC"); - assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER); - } - - @Test - public void parseRubyTagWithNoTextTag() throws Exception { - Spanned text = parseCueText("Some base text with no ruby text"); - - assertThat(text.toString()).isEqualTo("Some base text with no ruby text"); - assertThat(text).hasNoSpans(); - } - - @Test - public void parseRubyTagWithEmptyTextTag() throws Exception { - Spanned text = parseCueText("Some base text with empty ruby text"); - - assertThat(text.toString()).isEqualTo("Some base text with empty ruby text"); - assertThat(text) - .hasRubySpanBetween("Some ".length(), "Some base text with".length()) - .withTextAndPosition("", RubySpan.POSITION_OVER); - } - @Test public void parseDefaultTextColor() throws Exception { Spanned text = parseCueText("In this sentence this text is red"); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java index a2fcfd2f01..128e7b4692 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java @@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.SubtitleDecoderException; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.common.collect.Iterables; @@ -48,6 +49,7 @@ public class WebvttDecoderTest { private static final String WITH_OVERLAPPING_TIMESTAMPS_FILE = "webvtt/with_overlapping_timestamps"; private static final String WITH_VERTICAL_FILE = "webvtt/with_vertical"; + private static final String WITH_RUBIES_FILE = "webvtt/with_rubies"; private static final String WITH_BAD_CUE_HEADER_FILE = "webvtt/with_bad_cue_header"; private static final String WITH_TAGS_FILE = "webvtt/with_tags"; private static final String WITH_CSS_STYLES = "webvtt/with_css_styles"; @@ -345,6 +347,51 @@ public class WebvttDecoderTest { assertThat(thirdCue.verticalType).isEqualTo(Cue.TYPE_UNSET); } + @Test + public void decodeWithRubies() throws Exception { + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_RUBIES_FILE); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(8); + + // Check that an explicit `over` position is read from CSS. + Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))); + assertThat(firstCue.text.toString()).isEqualTo("Some text with over-ruby."); + assertThat((Spanned) firstCue.text) + .hasRubySpanBetween("Some ".length(), "Some text with over-ruby".length()) + .withTextAndPosition("over", RubySpan.POSITION_OVER); + + // Check that `under` is read from CSS and unspecified defaults to `over`. + Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2))); + assertThat(secondCue.text.toString()) + .isEqualTo("Some text with under-ruby and over-ruby (default)."); + assertThat((Spanned) secondCue.text) + .hasRubySpanBetween("Some ".length(), "Some text with under-ruby".length()) + .withTextAndPosition("under", RubySpan.POSITION_UNDER); + assertThat((Spanned) secondCue.text) + .hasRubySpanBetween( + "Some text with under-ruby and ".length(), + "Some text with under-ruby and over-ruby (default)".length()) + .withTextAndPosition("over", RubySpan.POSITION_OVER); + + // Check many tags nested in a single span. + Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); + assertThat(thirdCue.text.toString()).isEqualTo("base1base2base3."); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween(/* start= */ 0, "base1".length()) + .withTextAndPosition("text1", RubySpan.POSITION_OVER); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween("base1".length(), "base1base2".length()) + .withTextAndPosition("text2", RubySpan.POSITION_OVER); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween("base1base2".length(), "base1base2base3".length()) + .withTextAndPosition("text3", RubySpan.POSITION_OVER); + + // Check a span with no tags. + Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); + assertThat(fourthCue.text.toString()).isEqualTo("Some text with no ruby text."); + assertThat((Spanned) fourthCue.text).hasNoSpans(); + } + @Test public void decodeWithBadCueHeader() throws Exception { WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_BAD_CUE_HEADER_FILE); diff --git a/testdata/src/test/assets/webvtt/with_rubies b/testdata/src/test/assets/webvtt/with_rubies new file mode 100644 index 0000000000..9b448632fa --- /dev/null +++ b/testdata/src/test/assets/webvtt/with_rubies @@ -0,0 +1,25 @@ +WEBVTT + +STYLE +::cue(.under) { + ruby-position: under; +} + +STYLE +::cue(.over) { + ruby-position: over; +} + +00:00:01.000 --> 00:00:02.000 +Some text with over-rubyover. + +00:00:03.000 --> 00:00:04.000 +Some text with under-rubyunder and over-ruby (default)over. + +NOTE Many individual rubies in a single tag + +00:00:05.000 --> 00:00:06.000 +base1text1base2text2base3text3. + +00:00:07.000 --> 00:00:08.000 +Some text with no ruby text.