From 3aa52c231720eaed88cdf27eff0f97d4bcf7625f Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 21 Jan 2020 10:49:56 +0000 Subject: [PATCH] Add vertical text support to TtmlDecoder I needed to use Cue.Builder instead of just SpannableStringBuilder for the regionOutput values, so I could attach the vertical info where appropriate (since this is a property of the Cue, not a span). PiperOrigin-RevId: 290709294 --- .../google/android/exoplayer2/text/Cue.java | 159 +++++++++++++++++- .../exoplayer2/text/ttml/TtmlDecoder.java | 15 ++ .../exoplayer2/text/ttml/TtmlNode.java | 86 +++++----- .../exoplayer2/text/ttml/TtmlStyle.java | 16 ++ .../src/test/assets/ttml/vertical_text.xml | 17 ++ .../exoplayer2/text/ttml/TtmlDecoderTest.java | 21 +++ 6 files changed, 270 insertions(+), 44 deletions(-) create mode 100644 library/core/src/test/assets/ttml/vertical_text.xml diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java index 889c61eb4a..fa7f2cb144 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java @@ -497,12 +497,36 @@ public final class Cue { return this; } - /** Sets the cue image. */ + /** + * Gets the cue text. + * + * @see Cue#text + */ + @Nullable + public CharSequence getText() { + return text; + } + + /** + * Sets the cue image. + * + * @see Cue#bitmap + */ public Builder setBitmap(Bitmap bitmap) { this.bitmap = bitmap; return this; } + /** + * Gets the cue image. + * + * @see Cue#bitmap + */ + @Nullable + public Bitmap getBitmap() { + return bitmap; + } + /** * Sets the alignment of the cue text within the cue box. * @@ -515,6 +539,16 @@ public final class Cue { return this; } + /** + * Gets the alignment of the cue text within the cue box, or null if the alignment is undefined. + * + * @see Cue#textAlignment + */ + @Nullable + public Alignment getTextAlignment() { + return textAlignment; + } + /** * Sets the position of the {@code lineAnchor} of the cue box within the viewport in the * direction orthogonal to the writing direction. @@ -561,6 +595,26 @@ public final class Cue { return this; } + /** + * Gets the position of the {@code lineAnchor} of the cue box within the viewport in the + * direction orthogonal to the writing direction. + * + * @see Cue#line + */ + public float getLine() { + return line; + } + + /** + * Gets the type of the value of {@link #getLine()}. + * + * @see Cue#lineType + */ + @LineType + public int getLineType() { + return lineType; + } + /** * Sets the cue box anchor positioned by {@link #setLine(float, int) line}. * @@ -575,6 +629,16 @@ public final class Cue { return this; } + /** + * Gets the cue box anchor positioned by {@link #setLine(float, int) line}. + * + * @see Cue#lineAnchor + */ + @AnchorType + public int getLineAnchor() { + return lineAnchor; + } + /** * Sets the fractional position of the {@link #setPositionAnchor(int) positionAnchor} of the cue * box within the viewport in the direction orthogonal to {@link #setLine(float, int) line}. @@ -590,6 +654,16 @@ public final class Cue { return this; } + /** + * Gets the fractional position of the {@link #setPositionAnchor(int) positionAnchor} of the cue + * box within the viewport in the direction orthogonal to {@link #setLine(float, int) line}. + * + * @see Cue#position + */ + public float getPosition() { + return position; + } + /** * Sets the cue box anchor positioned by {@link #setPosition(float) position}. * @@ -605,7 +679,17 @@ public final class Cue { } /** - * Sets the default text size type for this cue's text. + * Gets the cue box anchor positioned by {@link #setPosition(float) position}. + * + * @see Cue#positionAnchor + */ + @AnchorType + public int getPositionAnchor() { + return positionAnchor; + } + + /** + * Sets the default text size and type for this cue's text. * * @see Cue#textSize * @see Cue#textSizeType @@ -616,12 +700,29 @@ public final class Cue { return this; } + /** + * Gets the default text size type for this cue's text. + * + * @see Cue#textSizeType + */ + @TextSizeType + public int getTextSizeType() { + return textSizeType; + } + + /** + * Gets the default text size for this cue's text. + * + * @see Cue#textSize + */ + public float getTextSize() { + return textSize; + } + /** * Sets the size of the cue box in the writing direction specified as a fraction of the viewport * size in that direction. * - * @see Cue#textSize - * @see Cue#textSizeType * @see Cue#size */ public Builder setSize(float size) { @@ -630,7 +731,17 @@ public final class Cue { } /** - * Sets the bitmap height as a fraction of the of the viewport size. + * Gets the size of the cue box in the writing direction specified as a fraction of the viewport + * size in that direction. + * + * @see Cue#size + */ + public float getSize() { + return size; + } + + /** + * Sets the bitmap height as a fraction of the viewport size. * * @see Cue#bitmapHeight */ @@ -639,6 +750,15 @@ public final class Cue { return this; } + /** + * Gets the bitmap height as a fraction of the viewport size. + * + * @see Cue#bitmapHeight + */ + public float getBitmapHeight() { + return bitmapHeight; + } + /** * Sets the fill color of the window. * @@ -653,6 +773,25 @@ public final class Cue { return this; } + /** + * Returns true if the fill color of the window is set. + * + * @see Cue#windowColorSet + */ + public boolean isWindowColorSet() { + return windowColorSet; + } + + /** + * Gets the fill color of the window. + * + * @see Cue#windowColor + */ + @ColorInt + public int getWindowColor() { + return windowColor; + } + /** * Sets the vertical formatting for this Cue. * @@ -663,6 +802,16 @@ public final class Cue { return this; } + /** + * Gets the vertical formatting for this Cue. + * + * @see Cue#verticalType + */ + @VerticalType + public int getVerticalType() { + return verticalType; + } + /** Build the cue. */ public Cue build() { return new Cue( 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 881460a49a..ff5bc21900 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 @@ -540,6 +540,21 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { break; } break; + case TtmlNode.ATTR_TTS_WRITING_MODE: + switch (Util.toLowerInvariant(attributeValue)) { + // TODO: Support horizontal RTL modes. + case TtmlNode.VERTICAL: + case TtmlNode.VERTICAL_LR: + style = createIfNull(style).setVerticalType(Cue.VERTICAL_TYPE_LR); + break; + case TtmlNode.VERTICAL_RL: + style = createIfNull(style).setVerticalType(Cue.VERTICAL_TYPE_RL); + break; + default: + // ignore + break; + } + 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 aee5b07632..29a8ccebec 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 @@ -28,7 +28,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.TreeMap; import java.util.TreeSet; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -67,7 +66,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public static final String ATTR_TTS_COLOR = "color"; public static final String ATTR_TTS_TEXT_DECORATION = "textDecoration"; public static final String ATTR_TTS_TEXT_ALIGN = "textAlign"; + public static final String ATTR_TTS_WRITING_MODE = "writingMode"; + // Values for textDecoration public static final String LINETHROUGH = "linethrough"; public static final String NO_LINETHROUGH = "nolinethrough"; public static final String UNDERLINE = "underline"; @@ -75,12 +76,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public static final String ITALIC = "italic"; public static final String BOLD = "bold"; + // Values for textAlign public static final String LEFT = "left"; public static final String CENTER = "center"; public static final String RIGHT = "right"; public static final String START = "start"; public static final String END = "end"; + // Values for writingMode + public static final String VERTICAL = "tb"; + public static final String VERTICAL_LR = "tblr"; + public static final String VERTICAL_RL = "tbrl"; + @Nullable public final String tag; @Nullable public final String text; public final boolean isTextNode; @@ -211,7 +218,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; List> regionImageOutputs = new ArrayList<>(); traverseForImage(timeUs, regionId, regionImageOutputs); - TreeMap regionTextOutputs = new TreeMap<>(); + TreeMap regionTextOutputs = new TreeMap<>(); traverseForText(timeUs, false, regionId, regionTextOutputs); traverseForStyle(timeUs, globalStyles, regionTextOutputs); @@ -242,20 +249,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } // Create text based cues. - for (Entry entry : regionTextOutputs.entrySet()) { + for (Map.Entry entry : regionTextOutputs.entrySet()) { TtmlRegion region = Assertions.checkNotNull(regionMap.get(entry.getKey())); - cues.add( - new Cue( - cleanUpText(entry.getValue()), - /* textAlignment= */ null, - region.line, - region.lineType, - region.lineAnchor, - region.position, - /* positionAnchor= */ Cue.TYPE_UNSET, - region.width, - region.textSizeType, - region.textSize)); + Cue.Builder regionOutput = entry.getValue(); + cleanUpText((SpannableStringBuilder) Assertions.checkNotNull(regionOutput.getText())); + regionOutput.setLine(region.line, region.lineType); + regionOutput.setLineAnchor(region.lineAnchor); + regionOutput.setPosition(region.position); + regionOutput.setSize(region.width); + regionOutput.setTextSize(region.textSize, region.textSizeType); + cues.add(regionOutput.build()); } return cues; @@ -277,7 +280,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; long timeUs, boolean descendsPNode, String inheritedRegion, - Map regionOutputs) { + Map regionOutputs) { nodeStartsByRegion.clear(); nodeEndsByRegion.clear(); if (TAG_METADATA.equals(tag)) { @@ -288,13 +291,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; if (isTextNode && descendsPNode) { - getRegionOutput(resolvedRegionId, regionOutputs).append(Assertions.checkNotNull(text)); + getRegionOutputText(resolvedRegionId, regionOutputs).append(Assertions.checkNotNull(text)); } else if (TAG_BR.equals(tag) && descendsPNode) { - getRegionOutput(resolvedRegionId, regionOutputs).append('\n'); + getRegionOutputText(resolvedRegionId, regionOutputs).append('\n'); } else if (isActive(timeUs)) { // This is a container node, which can contain zero or more children. - for (Entry entry : regionOutputs.entrySet()) { - nodeStartsByRegion.put(entry.getKey(), entry.getValue().length()); + for (Map.Entry entry : regionOutputs.entrySet()) { + nodeStartsByRegion.put( + entry.getKey(), Assertions.checkNotNull(entry.getValue().getText()).length()); } boolean isPNode = TAG_P.equals(tag); @@ -303,36 +307,38 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutputs); } if (isPNode) { - TtmlRenderUtil.endParagraph(getRegionOutput(resolvedRegionId, regionOutputs)); + TtmlRenderUtil.endParagraph(getRegionOutputText(resolvedRegionId, regionOutputs)); } - for (Entry entry : regionOutputs.entrySet()) { - nodeEndsByRegion.put(entry.getKey(), entry.getValue().length()); + for (Map.Entry entry : regionOutputs.entrySet()) { + nodeEndsByRegion.put( + entry.getKey(), Assertions.checkNotNull(entry.getValue().getText()).length()); } } } - private static SpannableStringBuilder getRegionOutput( - String resolvedRegionId, Map regionOutputs) { + private static SpannableStringBuilder getRegionOutputText( + String resolvedRegionId, Map regionOutputs) { if (!regionOutputs.containsKey(resolvedRegionId)) { - regionOutputs.put(resolvedRegionId, new SpannableStringBuilder()); + Cue.Builder regionOutput = new Cue.Builder(); + regionOutput.setText(new SpannableStringBuilder()); + regionOutputs.put(resolvedRegionId, regionOutput); } - return regionOutputs.get(resolvedRegionId); + return (SpannableStringBuilder) + Assertions.checkNotNull(regionOutputs.get(resolvedRegionId).getText()); } private void traverseForStyle( - long timeUs, - Map globalStyles, - Map regionOutputs) { + long timeUs, Map globalStyles, Map regionOutputs) { if (!isActive(timeUs)) { return; } - for (Entry entry : nodeEndsByRegion.entrySet()) { + 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) { - SpannableStringBuilder regionOutput = Assertions.checkNotNull(regionOutputs.get(regionId)); + Cue.Builder regionOutput = Assertions.checkNotNull(regionOutputs.get(regionId)); applyStyleToOutput(globalStyles, regionOutput, start, end); } } @@ -342,17 +348,20 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } private void applyStyleToOutput( - Map globalStyles, - SpannableStringBuilder regionOutput, - int start, - int end) { + Map globalStyles, Cue.Builder regionOutput, int start, int end) { @Nullable TtmlStyle resolvedStyle = TtmlRenderUtil.resolveStyle(style, styleIds, globalStyles); + @Nullable SpannableStringBuilder text = (SpannableStringBuilder) regionOutput.getText(); + if (text == null) { + text = new SpannableStringBuilder(); + regionOutput.setText(text); + } if (resolvedStyle != null) { - TtmlRenderUtil.applyStylesToSpan(regionOutput, start, end, resolvedStyle); + TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle); + regionOutput.setVerticalType(resolvedStyle.getVerticalType()); } } - private SpannableStringBuilder cleanUpText(SpannableStringBuilder builder) { + 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. int builderLength = builder.length(); @@ -396,7 +405,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; builder.delete(builderLength - 1, builderLength); /*builderLength--;*/ } - return builder; } } 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 0c7c90afc5..b68b650bab 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 @@ -19,6 +19,8 @@ import android.graphics.Typeface; import android.text.Layout; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.Cue.VerticalType; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -73,6 +75,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private float fontSize; private @MonotonicNonNull String id; private Layout.@MonotonicNonNull Alignment textAlign; + @Cue.VerticalType private int verticalType; public TtmlStyle() { linethrough = UNSPECIFIED; @@ -80,6 +83,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; + verticalType = Cue.TYPE_UNSET; } /** @@ -220,6 +224,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) { setBackgroundColor(ancestor.backgroundColor); } + if (chaining && verticalType != Cue.TYPE_UNSET && ancestor.verticalType == Cue.TYPE_UNSET) { + setVerticalType(ancestor.verticalType); + } } return this; } @@ -262,4 +269,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return fontSize; } + public TtmlStyle setVerticalType(@VerticalType int verticalType) { + this.verticalType = verticalType; + return this; + } + + @VerticalType + public int getVerticalType() { + return verticalType; + } } diff --git a/library/core/src/test/assets/ttml/vertical_text.xml b/library/core/src/test/assets/ttml/vertical_text.xml new file mode 100644 index 0000000000..181e93114c --- /dev/null +++ b/library/core/src/test/assets/ttml/vertical_text.xml @@ -0,0 +1,17 @@ + + +
+

Vertical right-to-left (e.g. Japanese)

+
+
+

Vertical left-to-right (e.g. Mongolian)

+
+
+

Horizontal text

+
+ +
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 22c7288340..0f1b117fb4 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 @@ -66,6 +66,7 @@ public final class TtmlDecoderTest { private static final String BITMAP_REGION_FILE = "ttml/bitmap_percentage_region.xml"; private static final String BITMAP_PIXEL_REGION_FILE = "ttml/bitmap_pixel_region.xml"; private static final String BITMAP_UNSUPPORTED_REGION_FILE = "ttml/bitmap_unsupported_region.xml"; + private static final String VERTICAL_TEXT_FILE = "ttml/vertical_text.xml"; @Test public void testInlineAttributes() throws IOException, SubtitleDecoderException { @@ -587,6 +588,26 @@ public final class TtmlDecoderTest { assertThat(cue.bitmapHeight).isEqualTo(Cue.DIMEN_UNSET); } + @Test + public void testVerticalText() throws IOException, SubtitleDecoderException { + TtmlSubtitle subtitle = getSubtitle(VERTICAL_TEXT_FILE); + + List firstCues = subtitle.getCues(10_000_000); + assertThat(firstCues).hasSize(1); + Cue firstCue = firstCues.get(0); + assertThat(firstCue.verticalType).isEqualTo(Cue.VERTICAL_TYPE_RL); + + List secondCues = subtitle.getCues(20_000_000); + assertThat(secondCues).hasSize(1); + Cue secondCue = secondCues.get(0); + assertThat(secondCue.verticalType).isEqualTo(Cue.VERTICAL_TYPE_LR); + + List thirdCues = subtitle.getCues(30_000_000); + assertThat(thirdCues).hasSize(1); + Cue thirdCue = thirdCues.get(0); + assertThat(thirdCue.verticalType).isEqualTo(Cue.TYPE_UNSET); + } + private void assertSpans( TtmlSubtitle subtitle, int second,