From 786a1ee82f1f5ec16377f606408181b6e7e3d2e2 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 19 Feb 2020 11:14:47 +0000 Subject: [PATCH] Add ruby support to TtmlDecoder I had to expand the info that gets passed around a bit, so I could join up the container, base & text ruby nodes. Spec: https://www.w3.org/TR/2018/REC-ttml2-20181108/#style-attribute-ruby PiperOrigin-RevId: 295931653 --- RELEASENOTES.md | 2 + .../exoplayer2/text/ttml/DeleteTextSpan.java | 30 +++++++ .../exoplayer2/text/ttml/TtmlDecoder.java | 38 +++++++- .../exoplayer2/text/ttml/TtmlNode.java | 41 +++++++-- .../exoplayer2/text/ttml/TtmlRenderUtil.java | 88 ++++++++++++++++++- .../exoplayer2/text/ttml/TtmlStyle.java | 45 +++++++++- .../exoplayer2/text/ttml/TtmlDecoderTest.java | 34 +++++++ .../exoplayer2/text/ttml/TtmlStyleTest.java | 29 ++++++ testdata/src/test/assets/ttml/rubies.xml | 78 ++++++++++++++++ 9 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/text/ttml/DeleteTextSpan.java create mode 100644 testdata/src/test/assets/ttml/rubies.xml diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 320a4e7494..dd3c858c3e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -49,6 +49,8 @@ * Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing, allowing playback to continue even if subtitles fail ([#6885](https://github.com/google/ExoPlayer/issues/6885)). + * Parse `tts:ruby` and `tts:rubyPosition` properties in TTML subtitles + (rendering is coming later). * DRM: * Add support for attaching DRM sessions to clear content in the demo app. * Remove `DrmSessionManager` references from all renderers. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/DeleteTextSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/DeleteTextSpan.java new file mode 100644 index 0000000000..be41c3957c --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/DeleteTextSpan.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.google.android.exoplayer2.text.ttml; + +import android.text.Spanned; + +/** + * A span used to mark a section of text for later deletion. + * + *

This is deliberately package-private because it's not generally supported by Android and + * results in surprising behaviour when simply calling {@link Spanned#toString} (i.e. the text isn't + * deleted). + * + *

This span is explicitly handled in {@code TtmlNode#cleanUpText}. + */ +/* package */ final class DeleteTextSpan {} 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 09df870bda..80009d4aac 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 @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.SimpleSubtitleDecoder; 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.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.android.exoplayer2.util.Log; @@ -537,6 +538,40 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { break; } break; + case TtmlNode.ATTR_TTS_RUBY: + switch (Util.toLowerInvariant(attributeValue)) { + case TtmlNode.RUBY_CONTAINER: + style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_CONTAINER); + break; + case TtmlNode.RUBY_BASE: + case TtmlNode.RUBY_BASE_CONTAINER: + style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_BASE); + break; + case TtmlNode.RUBY_TEXT: + case TtmlNode.RUBY_TEXT_CONTAINER: + style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_TEXT); + break; + case TtmlNode.RUBY_DELIMITER: + style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_DELIMITER); + break; + default: + // ignore + break; + } + break; + case TtmlNode.ATTR_TTS_RUBY_POSITION: + switch (Util.toLowerInvariant(attributeValue)) { + case TtmlNode.RUBY_BEFORE: + style = createIfNull(style).setRubyPosition(RubySpan.POSITION_OVER); + break; + case TtmlNode.RUBY_AFTER: + style = createIfNull(style).setRubyPosition(RubySpan.POSITION_UNDER); + break; + default: + // ignore + break; + } + break; case TtmlNode.ATTR_TTS_TEXT_DECORATION: switch (Util.toLowerInvariant(attributeValue)) { case TtmlNode.LINETHROUGH: @@ -650,8 +685,9 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { endTime = parent.endTimeUs; } } + return TtmlNode.buildNode( - parser.getName(), startTime, endTime, style, styleIds, regionId, imageId); + parser.getName(), startTime, endTime, style, styleIds, regionId, imageId, parent); } private static boolean isSupportedTag(String tag) { 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 df59639d82..c8e9ed7ce0 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 @@ -64,11 +64,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public static final String ATTR_TTS_FONT_FAMILY = "fontFamily"; public static final String ATTR_TTS_FONT_WEIGHT = "fontWeight"; public static final String ATTR_TTS_COLOR = "color"; + public static final String ATTR_TTS_RUBY = "ruby"; + public static final String ATTR_TTS_RUBY_POSITION = "rubyPosition"; 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_WRITING_MODE = "writingMode"; + // Values for ruby + public static final String RUBY_CONTAINER = "container"; + public static final String RUBY_BASE = "base"; + public static final String RUBY_BASE_CONTAINER = "baseContainer"; + public static final String RUBY_TEXT = "text"; + public static final String RUBY_TEXT_CONTAINER = "textContainer"; + public static final String RUBY_DELIMITER = "delimiter"; + + // Values for rubyPosition + public static final String RUBY_BEFORE = "before"; + public static final String RUBY_AFTER = "after"; // Values for textDecoration public static final String LINETHROUGH = "linethrough"; public static final String NO_LINETHROUGH = "nolinethrough"; @@ -102,6 +115,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable private final String[] styleIds; public final String regionId; @Nullable public final String imageId; + @Nullable public final TtmlNode parent; private final HashMap nodeStartsByRegion; private final HashMap nodeEndsByRegion; @@ -117,7 +131,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /* style= */ null, /* styleIds= */ null, ANONYMOUS_REGION_ID, - /* imageId= */ null); + /* imageId= */ null, + /* parent= */ null); } public static TtmlNode buildNode( @@ -127,9 +142,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable TtmlStyle style, @Nullable String[] styleIds, String regionId, - @Nullable String imageId) { + @Nullable String imageId, + @Nullable TtmlNode parent) { return new TtmlNode( - tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId); + tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId, parent); } private TtmlNode( @@ -140,7 +156,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable TtmlStyle style, @Nullable String[] styleIds, String regionId, - @Nullable String imageId) { + @Nullable String imageId, + @Nullable TtmlNode parent) { this.tag = tag; this.text = text; this.imageId = imageId; @@ -150,6 +167,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; this.startTimeUs = startTimeUs; this.endTimeUs = endTimeUs; this.regionId = Assertions.checkNotNull(regionId); + this.parent = parent; nodeStartsByRegion = new HashMap<>(); nodeEndsByRegion = new HashMap<>(); } @@ -361,14 +379,19 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutput.setText(text); } if (resolvedStyle != null) { - TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle); + TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent); regionOutput.setVerticalType(resolvedStyle.getVerticalType()); } } private static void cleanUpText(SpannableStringBuilder builder) { // Having joined the text elements, we need to do some final cleanup on the result. - // 1. Collapse multiple consecutive spaces into a single space. + // Remove any text covered by a DeleteTextSpan (e.g. ruby text). + DeleteTextSpan[] deleteTextSpans = builder.getSpans(0, builder.length(), DeleteTextSpan.class); + for (DeleteTextSpan deleteTextSpan : deleteTextSpans) { + builder.replace(builder.getSpanStart(deleteTextSpan), builder.getSpanEnd(deleteTextSpan), ""); + } + // Collapse multiple consecutive spaces into a single space. for (int i = 0; i < builder.length(); i++) { if (builder.charAt(i) == ' ') { int j = i + 1; @@ -381,7 +404,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } } } - // 2. Remove any spaces from the start of each line. + // Remove any spaces from the start of each line. if (builder.length() > 0 && builder.charAt(0) == ' ') { builder.delete(0, 1); } @@ -390,7 +413,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; builder.delete(i + 1, i + 2); } } - // 3. Remove any spaces from the end of each line. + // Remove any spaces from the end of each line. if (builder.length() > 0 && builder.charAt(builder.length() - 1) == ' ') { builder.delete(builder.length() - 1, builder.length()); } @@ -399,7 +422,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; builder.delete(i, i + 1); } } - // 4. Trim a trailing newline, if there is one. + // Trim a trailing newline, if there is one. if (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') { builder.delete(builder.length() - 1, builder.length()); } 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 f209936b39..e5ba2c9c1c 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.text.ttml; import android.text.Layout.Alignment; +import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.style.AbsoluteSizeSpan; @@ -29,7 +30,12 @@ import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; 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.SpanUtil; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.Map; /** @@ -37,6 +43,8 @@ import java.util.Map; */ /* package */ final class TtmlRenderUtil { + private static final String TAG = "TtmlRenderUtil"; + @Nullable public static TtmlStyle resolveStyle( @Nullable TtmlStyle style, @Nullable String[] styleIds, Map globalStyles) { @@ -71,8 +79,8 @@ import java.util.Map; return style; } - public static void applyStylesToSpan(SpannableStringBuilder builder, - int start, int end, TtmlStyle style) { + public static void applyStylesToSpan( + Spannable builder, int start, int end, TtmlStyle style, @Nullable TtmlNode parent) { if (style.getStyle() != TtmlStyle.UNSPECIFIED) { builder.setSpan(new StyleSpan(style.getStyle()), start, end, @@ -108,6 +116,53 @@ import java.util.Map; 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. + @Nullable TtmlNode containerNode = findRubyContainerNode(parent); + if (containerNode == null) { + // No matching container node + break; + } + @Nullable TtmlNode textNode = findRubyTextNode(containerNode); + if (textNode == null) { + // no matching text node + break; + } + String rubyText; + if (textNode.getChildCount() == 1 && textNode.getChild(0).text != null) { + rubyText = Util.castNonNull(textNode.getChild(0).text); + } else { + Log.i(TAG, "Skipping rubyText node without exactly one text child."); + break; + } + + // TODO: Get rubyPosition from `textNode` when TTML inheritance is implemented. + @RubySpan.Position + int rubyPosition = + containerNode.style != null + ? containerNode.style.getRubyPosition() + : RubySpan.POSITION_UNKNOWN; + builder.setSpan( + new RubySpan(rubyText, rubyPosition), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.RUBY_TYPE_DELIMITER: + // TODO: Add support for this when RubySpan supports parenthetical text. For now, just + // fall through and delete the text. + case TtmlStyle.RUBY_TYPE_TEXT: + // We can't just remove the text directly from `builder` here because TtmlNode has fixed + // ideas of where every node starts and ends (nodeStartsByRegion and nodeEndsByRegion) so + // all these indices become invalid if we mutate the underlying string at this point. + // Instead we add a special span that's then handled in TtmlNode#cleanUpText. + builder.setSpan(new DeleteTextSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.RUBY_TYPE_CONTAINER: + case TtmlStyle.UNSPECIFIED: + default: + // Do nothing + break; + } + @Nullable Alignment textAlign = style.getTextAlign(); if (textAlign != null) { SpanUtil.addOrReplaceSpan( @@ -156,6 +211,35 @@ import java.util.Map; } } + @Nullable + private static TtmlNode findRubyTextNode(TtmlNode rubyContainerNode) { + Deque childNodesStack = new ArrayDeque<>(); + childNodesStack.push(rubyContainerNode); + while (!childNodesStack.isEmpty()) { + TtmlNode childNode = childNodesStack.pop(); + if (childNode.style != null && childNode.style.getRubyType() == TtmlStyle.RUBY_TYPE_TEXT) { + return childNode; + } + for (int i = childNode.getChildCount() - 1; i >= 0; i--) { + childNodesStack.push(childNode.getChild(i)); + } + } + + return null; + } + + @Nullable + private static TtmlNode findRubyContainerNode(@Nullable TtmlNode node) { + while (node != null) { + @Nullable TtmlStyle style = node.style; + if (style != null && style.getRubyType() == TtmlStyle.RUBY_TYPE_CONTAINER) { + return node; + } + node = node.parent; + } + return null; + } + /** * Called when the end of a paragraph is encountered. Adds a newline if there are one or more * non-space characters since the previous newline. 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 929c4c02fc..928af3620c 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 @@ -21,6 +21,7 @@ import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Cue.VerticalType; +import com.google.android.exoplayer2.text.span.RubySpan; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -61,6 +62,16 @@ import java.lang.annotation.RetentionPolicy; private static final int OFF = 0; private static final int ON = 1; + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNSPECIFIED, RUBY_TYPE_CONTAINER, RUBY_TYPE_BASE, RUBY_TYPE_TEXT, RUBY_TYPE_DELIMITER}) + public @interface RubyType {} + + public static final int RUBY_TYPE_CONTAINER = 1; + public static final int RUBY_TYPE_BASE = 2; + public static final int RUBY_TYPE_TEXT = 3; + public static final int RUBY_TYPE_DELIMITER = 4; + @Nullable private String fontFamily; private int fontColor; private boolean hasFontColor; @@ -73,6 +84,8 @@ import java.lang.annotation.RetentionPolicy; @FontSizeUnit private int fontSizeUnit; private float fontSize; @Nullable private String id; + @RubyType private int rubyType; + @RubySpan.Position private int rubyPosition; @Nullable private Layout.Alignment textAlign; @OptionalBoolean private int textCombine; @Cue.VerticalType private int verticalType; @@ -83,6 +96,8 @@ import java.lang.annotation.RetentionPolicy; bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; + rubyType = UNSPECIFIED; + rubyPosition = RubySpan.POSITION_UNKNOWN; textCombine = UNSPECIFIED; verticalType = Cue.TYPE_UNSET; } @@ -214,6 +229,9 @@ import java.lang.annotation.RetentionPolicy; if (underline == UNSPECIFIED) { underline = ancestor.underline; } + if (rubyPosition == RubySpan.POSITION_UNKNOWN) { + rubyPosition = ancestor.rubyPosition; + } if (textAlign == null && ancestor.textAlign != null) { textAlign = ancestor.textAlign; } @@ -228,8 +246,11 @@ import java.lang.annotation.RetentionPolicy; if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) { setBackgroundColor(ancestor.backgroundColor); } - if (chaining && verticalType == Cue.TYPE_UNSET) { - verticalType = ancestor.verticalType; + if (chaining && rubyType == UNSPECIFIED && ancestor.rubyType != UNSPECIFIED) { + rubyType = ancestor.rubyType; + } + if (chaining && verticalType == Cue.TYPE_UNSET && ancestor.verticalType != Cue.TYPE_UNSET) { + setVerticalType(ancestor.verticalType); } } return this; @@ -245,6 +266,26 @@ import java.lang.annotation.RetentionPolicy; return id; } + public TtmlStyle setRubyType(@RubyType int rubyType) { + this.rubyType = rubyType; + return this; + } + + @RubyType + public int getRubyType() { + return rubyType; + } + + public TtmlStyle setRubyPosition(@RubySpan.Position int position) { + this.rubyPosition = position; + return this; + } + + @RubySpan.Position + public int getRubyPosition() { + return rubyPosition; + } + @Nullable public Layout.Alignment getTextAlign() { return textAlign; 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 7ec51fc009..54dc5c058d 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 @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.testutil.TestUtil; 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.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import java.io.IOException; @@ -61,6 +62,7 @@ public final class TtmlDecoderTest { private static final String BITMAP_UNSUPPORTED_REGION_FILE = "ttml/bitmap_unsupported_region.xml"; private static final String VERTICAL_TEXT_FILE = "ttml/vertical_text.xml"; private static final String TEXT_COMBINE_FILE = "ttml/text_combine.xml"; + private static final String RUBIES_FILE = "ttml/rubies.xml"; @Test public void testInlineAttributes() throws IOException, SubtitleDecoderException { @@ -606,6 +608,38 @@ public final class TtmlDecoderTest { assertThat(thirdCue).hasNoHorizontalTextInVerticalContextSpanBetween(0, thirdCue.length()); } + @Test + public void testRubies() throws IOException, SubtitleDecoderException { + TtmlSubtitle subtitle = getSubtitle(RUBIES_FILE); + + Spanned firstCue = getOnlyCueTextAtTimeUs(subtitle, 10_000_000); + assertThat(firstCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(firstCue) + .hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length()) + .withTextAndPosition("1st rubies", RubySpan.POSITION_OVER); + assertThat(firstCue) + .hasRubySpanBetween("Cue with annotated ".length(), "Cue with annotated text".length()) + .withTextAndPosition("2nd rubies", RubySpan.POSITION_UNKNOWN); + + Spanned secondCue = getOnlyCueTextAtTimeUs(subtitle, 20_000_000); + assertThat(secondCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(secondCue) + .hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length()) + .withTextAndPosition("rubies", RubySpan.POSITION_UNKNOWN); + + Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000); + assertThat(thirdCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(thirdCue).hasNoRubySpanBetween(0, thirdCue.length()); + + Spanned fourthCue = getOnlyCueTextAtTimeUs(subtitle, 40_000_000); + assertThat(fourthCue.toString()).isEqualTo("Cue with text."); + assertThat(fourthCue).hasNoRubySpanBetween(0, fourthCue.length()); + + Spanned fifthCue = getOnlyCueTextAtTimeUs(subtitle, 50_000_000); + assertThat(fifthCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(fifthCue).hasNoRubySpanBetween(0, fifthCue.length()); + } + 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 d2fb155f8a..8f6cc97a30 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 @@ -29,6 +29,7 @@ import android.text.Layout; import androidx.annotation.ColorInt; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.span.RubySpan; import org.junit.Test; import org.junit.runner.RunWith; @@ -42,6 +43,8 @@ public final class TtmlStyleTest { private static final float FONT_SIZE = 12.5f; @TtmlStyle.FontSizeUnit private static final int FONT_SIZE_UNIT = TtmlStyle.FONT_SIZE_UNIT_EM; @ColorInt private static final int BACKGROUND_COLOR = Color.BLACK; + private static final int RUBY_TYPE = TtmlStyle.RUBY_TYPE_TEXT; + 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; @Cue.VerticalType private static final int VERTICAL_TYPE = Cue.VERTICAL_TYPE_RL; @@ -58,6 +61,8 @@ public final class TtmlStyleTest { .setFontFamily(FONT_FAMILY) .setFontSize(FONT_SIZE) .setFontSizeUnit(FONT_SIZE_UNIT) + .setRubyType(RUBY_TYPE) + .setRubyPosition(RUBY_POSITION) .setTextAlign(TEXT_ALIGN) .setTextCombine(TEXT_COMBINE) .setVerticalType(VERTICAL_TYPE); @@ -75,8 +80,12 @@ public final class TtmlStyleTest { assertThat(style.getFontColor()).isEqualTo(FONT_COLOR); assertThat(style.getFontSize()).isEqualTo(FONT_SIZE); assertThat(style.getFontSizeUnit()).isEqualTo(FONT_SIZE_UNIT); + assertThat(style.getRubyPosition()).isEqualTo(RUBY_POSITION); assertThat(style.getTextAlign()).isEqualTo(TEXT_ALIGN); assertThat(style.getTextCombine()).isEqualTo(TEXT_COMBINE); + assertWithMessage("rubyType should not be inherited") + .that(style.getRubyType()) + .isEqualTo(UNSPECIFIED); assertWithMessage("backgroundColor should not be inherited") .that(style.hasBackgroundColor()) .isFalse(); @@ -99,11 +108,13 @@ public final class TtmlStyleTest { assertThat(style.getFontColor()).isEqualTo(FONT_COLOR); assertThat(style.getFontSize()).isEqualTo(FONT_SIZE); assertThat(style.getFontSizeUnit()).isEqualTo(FONT_SIZE_UNIT); + assertThat(style.getRubyPosition()).isEqualTo(RUBY_POSITION); assertThat(style.getTextAlign()).isEqualTo(TEXT_ALIGN); assertThat(style.getTextCombine()).isEqualTo(TEXT_COMBINE); assertWithMessage("backgroundColor should be chained") .that(style.getBackgroundColor()) .isEqualTo(BACKGROUND_COLOR); + assertWithMessage("rubyType should be chained").that(style.getRubyType()).isEqualTo(RUBY_TYPE); assertWithMessage("verticalType should be chained") .that(style.getVerticalType()) .isEqualTo(VERTICAL_TYPE); @@ -206,6 +217,24 @@ public final class TtmlStyleTest { assertThat(style.getId()).isNull(); } + @Test + public void testRubyType() { + TtmlStyle style = new TtmlStyle(); + + assertThat(style.getRubyType()).isEqualTo(UNSPECIFIED); + style.setRubyType(TtmlStyle.RUBY_TYPE_BASE); + assertThat(style.getRubyType()).isEqualTo(TtmlStyle.RUBY_TYPE_BASE); + } + + @Test + public void testRubyPosition() { + TtmlStyle style = new TtmlStyle(); + + assertThat(style.getRubyPosition()).isEqualTo(RubySpan.POSITION_UNKNOWN); + style.setRubyPosition(RubySpan.POSITION_OVER); + assertThat(style.getRubyPosition()).isEqualTo(RubySpan.POSITION_OVER); + } + @Test public void testTextAlign() { TtmlStyle style = new TtmlStyle(); diff --git a/testdata/src/test/assets/ttml/rubies.xml b/testdata/src/test/assets/ttml/rubies.xml new file mode 100644 index 0000000000..0eb89da477 --- /dev/null +++ b/testdata/src/test/assets/ttml/rubies.xml @@ -0,0 +1,78 @@ + + + +

+ +

+ Cue with + + annotated + 1st rubies + + + 2nd rubies + text. + +

+
+
+ +

+ Cue with + + rubies + annotated + alt-text + + text. +

+
+
+ +

+ Cue with + + annotated + + text.

+
+
+ +

+ Cue with + + rubies + + text. +

+
+
+ +

+ Cue with + rubies + annotated + text. +

+
+ +