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
This commit is contained in:
ibaker 2020-01-21 10:49:56 +00:00 committed by Ian Baker
parent 37908dd4df
commit 3aa52c2317
6 changed files with 270 additions and 44 deletions

View File

@ -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(

View File

@ -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;

View File

@ -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<Pair<String, String>> regionImageOutputs = new ArrayList<>();
traverseForImage(timeUs, regionId, regionImageOutputs);
TreeMap<String, SpannableStringBuilder> regionTextOutputs = new TreeMap<>();
TreeMap<String, Cue.Builder> 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<String, SpannableStringBuilder> entry : regionTextOutputs.entrySet()) {
for (Map.Entry<String, Cue.Builder> 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<String, SpannableStringBuilder> regionOutputs) {
Map<String, Cue.Builder> 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<String, SpannableStringBuilder> entry : regionOutputs.entrySet()) {
nodeStartsByRegion.put(entry.getKey(), entry.getValue().length());
for (Map.Entry<String, Cue.Builder> 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<String, SpannableStringBuilder> entry : regionOutputs.entrySet()) {
nodeEndsByRegion.put(entry.getKey(), entry.getValue().length());
for (Map.Entry<String, Cue.Builder> entry : regionOutputs.entrySet()) {
nodeEndsByRegion.put(
entry.getKey(), Assertions.checkNotNull(entry.getValue().getText()).length());
}
}
}
private static SpannableStringBuilder getRegionOutput(
String resolvedRegionId, Map<String, SpannableStringBuilder> regionOutputs) {
private static SpannableStringBuilder getRegionOutputText(
String resolvedRegionId, Map<String, Cue.Builder> 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<String, TtmlStyle> globalStyles,
Map<String, SpannableStringBuilder> regionOutputs) {
long timeUs, Map<String, TtmlStyle> globalStyles, Map<String, Cue.Builder> regionOutputs) {
if (!isActive(timeUs)) {
return;
}
for (Entry<String, Integer> entry : nodeEndsByRegion.entrySet()) {
for (Map.Entry<String, Integer> 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<String, TtmlStyle> globalStyles,
SpannableStringBuilder regionOutput,
int start,
int end) {
Map<String, TtmlStyle> 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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,17 @@
<tt xmlns:ttm="http://www.w3.org/2006/10/ttaf1#metadata"
xmlns:ttp="http://www.w3.org/2006/10/ttaf1#parameter"
xmlns:tts="http://www.w3.org/2006/10/ttaf1#style"
xmlns="http://www.w3.org/ns/ttml"
xmlns="http://www.w3.org/2006/10/ttaf1">
<body>
<div>
<p begin="10s" end="18s" tts:writingMode="tbrl">Vertical right-to-left (e.g. Japanese)</p>
</div>
<div>
<p begin="20s" end="28s" tts:writingMode="tblr">Vertical left-to-right (e.g. Mongolian)</p>
</div>
<div>
<p begin="30s" end="38s">Horizontal text</p>
</div>
</body>
</tt>

View File

@ -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<Cue> firstCues = subtitle.getCues(10_000_000);
assertThat(firstCues).hasSize(1);
Cue firstCue = firstCues.get(0);
assertThat(firstCue.verticalType).isEqualTo(Cue.VERTICAL_TYPE_RL);
List<Cue> secondCues = subtitle.getCues(20_000_000);
assertThat(secondCues).hasSize(1);
Cue secondCue = secondCues.get(0);
assertThat(secondCue.verticalType).isEqualTo(Cue.VERTICAL_TYPE_LR);
List<Cue> 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,