Further improve WebVTT parser according to WebVTT spec

This commit is contained in:
Oliver Woodman 2015-09-28 12:20:27 +01:00
parent 71f542f7c2
commit e4e02f9189
10 changed files with 408 additions and 333 deletions

View File

@ -1,7 +0,0 @@
WEBVTT
00:00.000 --> 00:01.234
This is the first subtitle.
00:02.345 --> 00:03.456
This is the second subtitle.

View File

@ -8,7 +8,9 @@ with multiple lines
00:00.000 --> 00:01.234 00:00.000 --> 00:01.234
This is the first subtitle. This is the first subtitle.
NOTE Single line comment NOTE Single line comment with a space
NOTE Single line comment with a tab
2 2
00:02.345 --> 00:03.456 00:02.345 --> 00:03.456

View File

@ -16,7 +16,13 @@ NOTE Line as percentage and line alignment
00:04.000 --> 00:05.000 line:45%,end align:middle size:35% 00:04.000 --> 00:05.000 line:45%,end align:middle size:35%
This is the third subtitle. This is the third subtitle.
NOTE Line as absolute negative number and without line alignment NOTE Line as absolute negative number and without line alignment.
00:06.000 --> 00:07.000 line:-10 align:middle
This is the fourth subtitle.
NOTE The positioning alignment should be inherited from align.
00:07.000 --> 00:08.000 position:10% align:end size:10%
This is the fifth subtitle.
00:06.000 --> 00:07.000 line:-10 align:middle size:35%
This is the forth subtitle.

View File

@ -15,32 +15,31 @@
*/ */
package com.google.android.exoplayer.text.webvtt; package com.google.android.exoplayer.text.webvtt;
import android.test.InstrumentationTestCase;
import android.text.Layout;
import com.google.android.exoplayer.text.Cue; import com.google.android.exoplayer.text.Cue;
import android.test.InstrumentationTestCase;
import android.text.Layout.Alignment;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.List;
/** /**
* Unit test for {@link WebvttParser}. * Unit test for {@link WebvttParser}.
*/ */
public class WebvttParserTest extends InstrumentationTestCase { public class WebvttParserTest extends InstrumentationTestCase {
private static final String TYPICAL_WEBVTT_FILE = "webvtt/typical"; private static final String TYPICAL_FILE = "webvtt/typical";
private static final String TYPICAL_WITH_IDS_WEBVTT_FILE = "webvtt/typical_with_identifiers"; private static final String TYPICAL_WITH_IDS_FILE = "webvtt/typical_with_identifiers";
private static final String TYPICAL_WITH_TAGS_WEBVTT_FILE = "webvtt/typical_with_tags"; private static final String TYPICAL_WITH_COMMENTS_FILE = "webvtt/typical_with_comments";
private static final String TYPICAL_WITH_COMMENTS_WEBVTT_FILE = "webvtt/typical_with_comments"; private static final String WITH_POSITIONING_FILE = "webvtt/with_positioning";
private static final String TYPICAL_WITH_METADATA_WEBVTT_FILE = "webvtt/typical_with_metadata"; private static final String WITH_TAGS_FILE = "webvtt/with_tags";
private static final String LIVE_TYPICAL_WEBVTT_FILE = "webvtt/live_typical"; private static final String EMPTY_FILE = "webvtt/empty";
private static final String EMPTY_WEBVTT_FILE = "webvtt/empty";
public void testParseNullWebvttFile() throws IOException { public void testParseEmpty() throws IOException {
WebvttParser parser = new WebvttParser(); WebvttParser parser = new WebvttParser();
InputStream inputStream = InputStream inputStream = getInstrumentation().getContext().getResources().getAssets()
getInstrumentation().getContext().getResources().getAssets().open(EMPTY_WEBVTT_FILE); .open(EMPTY_FILE);
try { try {
parser.parse(inputStream); parser.parse(inputStream);
fail("Expected IOException"); fail("Expected IOException");
@ -49,189 +48,113 @@ public class WebvttParserTest extends InstrumentationTestCase {
} }
} }
public void testParseTypicalWebvttFile() throws IOException { public void testParseTypical() throws IOException {
WebvttParser parser = new WebvttParser(); WebvttParser parser = new WebvttParser();
InputStream inputStream = InputStream inputStream =
getInstrumentation().getContext().getResources().getAssets().open(TYPICAL_WEBVTT_FILE); getInstrumentation().getContext().getResources().getAssets().open(TYPICAL_FILE);
WebvttSubtitle subtitle = parser.parse(inputStream); WebvttSubtitle subtitle = parser.parse(inputStream);
// test event count // test event count
assertEquals(4, subtitle.getEventTimeCount()); assertEquals(4, subtitle.getEventTimeCount());
// test first cue // test cues
assertEquals(0, subtitle.getEventTime(0)); assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertEquals("This is the first subtitle.", assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString());
assertEquals(1234000, subtitle.getEventTime(1));
// test second cue
assertEquals(2345000, subtitle.getEventTime(2));
assertEquals("This is the second subtitle.",
subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString());
assertEquals(3456000, subtitle.getEventTime(3));
} }
public void testParseTypicalWithIdsWebvttFile() throws IOException { public void testParseTypicalWithIds() throws IOException {
WebvttParser parser = new WebvttParser(); WebvttParser parser = new WebvttParser();
InputStream inputStream = InputStream inputStream = getInstrumentation().getContext().getResources().getAssets()
getInstrumentation().getContext().getResources().getAssets() .open(TYPICAL_WITH_IDS_FILE);
.open(TYPICAL_WITH_IDS_WEBVTT_FILE);
WebvttSubtitle subtitle = parser.parse(inputStream); WebvttSubtitle subtitle = parser.parse(inputStream);
// test event count // test event count
assertEquals(4, subtitle.getEventTimeCount()); assertEquals(4, subtitle.getEventTimeCount());
// test first cue // test cues
assertEquals(0, subtitle.getEventTime(0)); assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertEquals("This is the first subtitle.", assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString());
assertEquals(1234000, subtitle.getEventTime(1));
// test second cue
assertEquals(2345000, subtitle.getEventTime(2));
assertEquals("This is the second subtitle.",
subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString());
assertEquals(3456000, subtitle.getEventTime(3));
} }
public void testParseTypicalWithTagsWebvttFile() throws IOException { public void testParseTypicalWithComments() throws IOException {
WebvttParser parser = new WebvttParser(); WebvttParser parser = new WebvttParser();
InputStream inputStream = InputStream inputStream = getInstrumentation().getContext().getResources().getAssets()
getInstrumentation().getContext().getResources().getAssets() .open(TYPICAL_WITH_COMMENTS_FILE);
.open(TYPICAL_WITH_TAGS_WEBVTT_FILE); WebvttSubtitle subtitle = parser.parse(inputStream);
// test event count
assertEquals(4, subtitle.getEventTimeCount());
// test cues
assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
}
public void testParseWithTags() throws IOException {
WebvttParser parser = new WebvttParser();
InputStream inputStream = getInstrumentation().getContext().getResources().getAssets()
.open(WITH_TAGS_FILE);
WebvttSubtitle subtitle = parser.parse(inputStream); WebvttSubtitle subtitle = parser.parse(inputStream);
// test event count // test event count
assertEquals(8, subtitle.getEventTimeCount()); assertEquals(8, subtitle.getEventTimeCount());
// test first cue // test cues
assertEquals(0, subtitle.getEventTime(0)); assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertEquals("This is the first subtitle.", assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); assertCue(subtitle, 4, 4000000, 5000000, "This is the third subtitle.");
assertEquals(1234000, subtitle.getEventTime(1)); assertCue(subtitle, 6, 6000000, 7000000, "This is the <fourth> &subtitle.");
// test second cue
assertEquals(2345000, subtitle.getEventTime(2));
assertEquals("This is the second subtitle.",
subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString());
assertEquals(3456000, subtitle.getEventTime(3));
// test third cue
assertEquals(4000000, subtitle.getEventTime(4));
assertEquals("This is the third subtitle.",
subtitle.getCues(subtitle.getEventTime(4)).get(0).text.toString());
assertEquals(5000000, subtitle.getEventTime(5));
// test fourth cue
assertEquals(6000000, subtitle.getEventTime(6));
assertEquals("This is the <fourth> &subtitle.",
subtitle.getCues(subtitle.getEventTime(6)).get(0).text.toString());
assertEquals(7000000, subtitle.getEventTime(7));
} }
public void testParseTypicalWithCommentsWebvttFile() throws IOException { public void testParseWithPositioning() throws IOException {
WebvttParser parser = new WebvttParser(); WebvttParser parser = new WebvttParser();
InputStream inputStream = InputStream inputStream = getInstrumentation().getContext().getResources().getAssets()
getInstrumentation().getContext().getResources().getAssets() .open(WITH_POSITIONING_FILE);
.open(TYPICAL_WITH_COMMENTS_WEBVTT_FILE);
WebvttSubtitle subtitle = parser.parse(inputStream); WebvttSubtitle subtitle = parser.parse(inputStream);
// test event count // test event count
assertEquals(4, subtitle.getEventTimeCount()); assertEquals(10, subtitle.getEventTimeCount());
// test first cue // test cues
assertEquals(0, subtitle.getEventTime(0)); assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.", Alignment.ALIGN_NORMAL,
assertEquals("This is the first subtitle.", Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, 0.1f, Cue.ANCHOR_TYPE_START, 0.35f);
subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.",
assertEquals(1234000, subtitle.getEventTime(1)); Alignment.ALIGN_OPPOSITE, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET,
Cue.TYPE_UNSET, 0.35f);
// test second cue assertCue(subtitle, 4, 4000000, 5000000, "This is the third subtitle.",
assertEquals(2345000, subtitle.getEventTime(2)); Alignment.ALIGN_CENTER, 0.45f, Cue.LINE_TYPE_FRACTION, Cue.ANCHOR_TYPE_END, Cue.DIMEN_UNSET,
assertEquals("This is the second subtitle.", Cue.TYPE_UNSET, 0.35f);
subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); assertCue(subtitle, 6, 6000000, 7000000, "This is the fourth subtitle.",
assertEquals(3456000, subtitle.getEventTime(3)); Alignment.ALIGN_CENTER, -10f, Cue.LINE_TYPE_NUMBER, Cue.TYPE_UNSET, Cue.DIMEN_UNSET,
Cue.TYPE_UNSET, Cue.DIMEN_UNSET);
assertCue(subtitle, 8, 7000000, 8000000, "This is the fifth subtitle.",
Alignment.ALIGN_OPPOSITE, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, 0.1f,
Cue.ANCHOR_TYPE_END, 0.1f);
} }
public void testParseTypicalWithMetadataWebvttFile() throws IOException { private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs,
WebvttParser parser = new WebvttParser(); int endTimeUs, String text) {
InputStream inputStream = assertCue(subtitle, eventTimeIndex, startTimeUs, endTimeUs, text, null, Cue.DIMEN_UNSET,
getInstrumentation().getContext().getResources().getAssets() Cue.TYPE_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET);
.open(TYPICAL_WITH_METADATA_WEBVTT_FILE);
WebvttSubtitle subtitle = parser.parse(inputStream);
// test event count
assertEquals(8, subtitle.getEventTimeCount());
// test first cue
assertEquals(0, subtitle.getEventTime(0));
assertEquals("This is the first subtitle.",
subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString());
assertEquals(10,
subtitle.getCues(subtitle.getEventTime(0)).get(0).position);
assertEquals(Layout.Alignment.ALIGN_NORMAL,
subtitle.getCues(subtitle.getEventTime(0)).get(0).alignment);
assertEquals(35,
subtitle.getCues(subtitle.getEventTime(0)).get(0).size);
assertEquals(1234000, subtitle.getEventTime(1));
// test second cue
assertEquals(2345000, subtitle.getEventTime(2));
assertEquals("This is the second subtitle.",
subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString());
assertEquals(Cue.UNSET_VALUE,
subtitle.getCues(subtitle.getEventTime(2)).get(0).position);
assertEquals(Layout.Alignment.ALIGN_OPPOSITE,
subtitle.getCues(subtitle.getEventTime(2)).get(0).alignment);
assertEquals(35,
subtitle.getCues(subtitle.getEventTime(2)).get(0).size);
assertEquals(3456000, subtitle.getEventTime(3));
// test third cue
assertEquals(4000000, subtitle.getEventTime(4));
assertEquals("This is the third subtitle.",
subtitle.getCues(subtitle.getEventTime(4)).get(0).text.toString());
assertEquals(45,
subtitle.getCues(subtitle.getEventTime(4)).get(0).line);
assertEquals(Layout.Alignment.ALIGN_CENTER,
subtitle.getCues(subtitle.getEventTime(4)).get(0).alignment);
assertEquals(35,
subtitle.getCues(subtitle.getEventTime(4)).get(0).size);
assertEquals(5000000, subtitle.getEventTime(5));
// test forth cue
assertEquals(6000000, subtitle.getEventTime(6));
assertEquals("This is the forth subtitle.",
subtitle.getCues(subtitle.getEventTime(6)).get(0).text.toString());
assertEquals(-10,
subtitle.getCues(subtitle.getEventTime(6)).get(0).line);
assertEquals(Layout.Alignment.ALIGN_CENTER,
subtitle.getCues(subtitle.getEventTime(6)).get(0).alignment);
assertEquals(35,
subtitle.getCues(subtitle.getEventTime(6)).get(0).size);
assertEquals(7000000, subtitle.getEventTime(7));
} }
public void testParseLiveTypicalWebvttFile() throws IOException { private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs,
WebvttParser parser = new WebvttParser(); int endTimeUs, String text, Alignment textAlignment, float line, int lineType, int lineAnchor,
InputStream inputStream = float position, int positionAnchor, float size) {
getInstrumentation().getContext().getResources().getAssets().open(LIVE_TYPICAL_WEBVTT_FILE); assertEquals(startTimeUs, subtitle.getEventTime(eventTimeIndex));
WebvttSubtitle subtitle = parser.parse(inputStream); assertEquals(endTimeUs, subtitle.getEventTime(eventTimeIndex + 1));
List<Cue> cues = subtitle.getCues(subtitle.getEventTime(eventTimeIndex));
// test event count assertEquals(1, cues.size());
long startTimeUs = 0; // Assert cue properties
assertEquals(4, subtitle.getEventTimeCount()); Cue cue = cues.get(0);
assertEquals(text, cue.text.toString());
// test first cue assertEquals(textAlignment, cue.textAlignment);
assertEquals(startTimeUs, subtitle.getEventTime(0)); assertEquals(line, cue.line);
assertEquals("This is the first subtitle.", assertEquals(lineType, cue.lineType);
subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); assertEquals(lineAnchor, cue.lineAnchor);
assertEquals(startTimeUs + 1234000, subtitle.getEventTime(1)); assertEquals(position, cue.position);
assertEquals(positionAnchor, cue.positionAnchor);
// test second cue assertEquals(size, cue.size);
assertEquals(startTimeUs + 2345000, subtitle.getEventTime(2));
assertEquals("This is the second subtitle.",
subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString());
assertEquals(startTimeUs + 3456000, subtitle.getEventTime(3));
} }
} }

View File

@ -23,30 +23,117 @@ import android.text.Layout.Alignment;
public class Cue { public class Cue {
/** /**
* Used by some methods to indicate that no value is set. * An unset position or width.
*/ */
public static final int UNSET_VALUE = -1; public static final float DIMEN_UNSET = Float.MIN_VALUE;
/**
* An unset anchor or line type value.
*/
public static final int TYPE_UNSET = Integer.MIN_VALUE;
/**
* Anchors the left (for horizontal positions) or top (for vertical positions) edge of the cue
* box.
*/
public static final int ANCHOR_TYPE_START = 0;
/**
* Anchors the middle of the cue box.
*/
public static final int ANCHOR_TYPE_MIDDLE = 1;
/**
* Anchors the right (for horizontal positions) or bottom (for vertical positions) edge of the cue
* box.
*/
public static final int ANCHOR_TYPE_END = 2;
/**
* Value for {@link #lineType} when {@link #line} is a fractional position.
*/
public static final int LINE_TYPE_FRACTION = 0;
/**
* Value for {@link #lineType} when {@link #line} is a line number.
*/
public static final int LINE_TYPE_NUMBER = 1;
/**
* The cue text. Note the {@link CharSequence} may be decorated with styling spans.
*/
public final CharSequence text; public final CharSequence text;
/**
public final int line; * The alignment of the cue text within the cue box.
public final int position; */
public final Alignment alignment; public final Alignment textAlignment;
public final int size; /**
* The position of the {@link #lineAnchor} of the cue box within the viewport in the direction
* orthogonal to the writing direction, or {@link #DIMEN_UNSET}. When set, the interpretation of
* the value depends on the value of {@link #lineType}.
* <p>
* For horizontal text and {@link #lineType} equal to {@link #LINE_TYPE_FRACTION}, this is the
* fractional vertical position relative to the top of the viewport.
*/
public final float line;
/**
* The type of the {@link #line} value.
* <p>
* {@link #LINE_TYPE_FRACTION} indicates that {@link #line} is a fractional position within the
* viewport.
* <p>
* {@link #LINE_TYPE_NUMBER} indicates that {@link #line} is a line number, where the size of each
* line is taken to be the size of the first line of the cue. When {@link #line} is greater than
* or equal to 0, lines count from the start of the viewport (the first line is numbered 0). When
* {@link #line} is negative, lines count from the end of the viewport (the last line is numbered
* -1). For horizontal text the size of the first line of the cue is its height, and the start
* and end of the viewport are the top and bottom respectively.
*/
public final int lineType;
/**
* The cue box anchor positioned by {@link #line}. One of {@link #ANCHOR_TYPE_START},
* {@link #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}.
* <p>
* For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link #ANCHOR_TYPE_MIDDLE}
* and {@link #ANCHOR_TYPE_END} correspond to the top, middle and bottom of the cue box
* respectively.
*/
public final int lineAnchor;
/**
* The fractional position of the {@link #positionAnchor} of the cue box within the viewport in
* the direction orthogonal to {@link #line}, or {@link #DIMEN_UNSET}.
* <p>
* For horizontal text, this is the horizontal position relative to the left of the viewport. Note
* that positioning is relative to the left of the viewport even in the case of right-to-left
* text.
*/
public final float position;
/**
* The cue box anchor positioned by {@link #position}. One of {@link #ANCHOR_TYPE_START},
* {@link #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}.
* <p>
* For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link #ANCHOR_TYPE_MIDDLE}
* and {@link #ANCHOR_TYPE_END} correspond to the left, middle and right of the cue box
* respectively.
*/
public final int positionAnchor;
/**
* The size of the cue box in the writing direction specified as a fraction of the viewport size
* in that direction, or {@link #DIMEN_UNSET}.
*/
public final float size;
public Cue() { public Cue() {
this(null); this(null);
} }
public Cue(CharSequence text) { public Cue(CharSequence text) {
this(text, UNSET_VALUE, UNSET_VALUE, null, UNSET_VALUE); this(text, null, DIMEN_UNSET, TYPE_UNSET, TYPE_UNSET, DIMEN_UNSET, TYPE_UNSET, DIMEN_UNSET);
} }
public Cue(CharSequence text, int line, int position, Alignment alignment, int size) { public Cue(CharSequence text, Alignment textAlignment, float line, int lineType,
int lineAnchor, float position, int positionAnchor, float size) {
this.text = text; this.text = text;
this.textAlignment = textAlignment;
this.line = line; this.line = line;
this.lineType = lineType;
this.lineAnchor = lineAnchor;
this.position = position; this.position = position;
this.alignment = alignment; this.positionAnchor = positionAnchor;
this.size = size; this.size = size;
} }

View File

@ -63,8 +63,13 @@ import android.util.Log;
// Previous input variables. // Previous input variables.
private CharSequence cueText; private CharSequence cueText;
private int cuePosition; private Alignment cueTextAlignment;
private Alignment cueAlignment; private float cueLine;
private int cueLineType;
private int cueLineAnchor;
private float cuePosition;
private int cuePositionAnchor;
private float cueSize;
private boolean applyEmbeddedStyles; private boolean applyEmbeddedStyles;
private int foregroundColor; private int foregroundColor;
private int backgroundColor; private int backgroundColor;
@ -120,7 +125,7 @@ import android.util.Log;
* @param style The style to use when drawing the cue text. * @param style The style to use when drawing the cue text.
* @param textSizePx The text size to use when drawing the cue text, in pixels. * @param textSizePx The text size to use when drawing the cue text, in pixels.
* @param bottomPaddingFraction The bottom padding fraction to apply when {@link Cue#line} is * @param bottomPaddingFraction The bottom padding fraction to apply when {@link Cue#line} is
* {@link Cue#UNSET_VALUE}, as a fraction of the viewport height * {@link Cue#DIMEN_UNSET}, as a fraction of the viewport height
* @param canvas The canvas into which to draw. * @param canvas The canvas into which to draw.
* @param cueBoxLeft The left position of the enclosing cue box. * @param cueBoxLeft The left position of the enclosing cue box.
* @param cueBoxTop The top position of the enclosing cue box. * @param cueBoxTop The top position of the enclosing cue box.
@ -140,8 +145,13 @@ import android.util.Log;
cueText = cueText.toString(); cueText = cueText.toString();
} }
if (areCharSequencesEqual(this.cueText, cueText) if (areCharSequencesEqual(this.cueText, cueText)
&& Util.areEqual(this.cueTextAlignment, cue.textAlignment)
&& this.cueLine == cue.line
&& this.cueLineType == cue.lineType
&& Util.areEqual(this.cueLineAnchor, cue.lineAnchor)
&& this.cuePosition == cue.position && this.cuePosition == cue.position
&& Util.areEqual(this.cueAlignment, cue.alignment) && Util.areEqual(this.cuePositionAnchor, cue.positionAnchor)
&& this.cueSize == cue.size
&& this.applyEmbeddedStyles == applyEmbeddedStyles && this.applyEmbeddedStyles == applyEmbeddedStyles
&& this.foregroundColor == style.foregroundColor && this.foregroundColor == style.foregroundColor
&& this.backgroundColor == style.backgroundColor && this.backgroundColor == style.backgroundColor
@ -161,8 +171,13 @@ import android.util.Log;
} }
this.cueText = cueText; this.cueText = cueText;
this.cueTextAlignment = cue.textAlignment;
this.cueLine = cue.line;
this.cueLineType = cue.lineType;
this.cueLineAnchor = cue.lineAnchor;
this.cuePosition = cue.position; this.cuePosition = cue.position;
this.cueAlignment = cue.alignment; this.cuePositionAnchor = cue.positionAnchor;
this.cueSize = cue.size;
this.applyEmbeddedStyles = applyEmbeddedStyles; this.applyEmbeddedStyles = applyEmbeddedStyles;
this.foregroundColor = style.foregroundColor; this.foregroundColor = style.foregroundColor;
this.backgroundColor = style.backgroundColor; this.backgroundColor = style.backgroundColor;
@ -182,16 +197,19 @@ import android.util.Log;
textPaint.setTextSize(textSizePx); textPaint.setTextSize(textSizePx);
int textPaddingX = (int) (textSizePx * INNER_PADDING_RATIO + 0.5f); int textPaddingX = (int) (textSizePx * INNER_PADDING_RATIO + 0.5f);
int availableWidth = parentWidth - textPaddingX * 2; int availableWidth = parentWidth - textPaddingX * 2;
if (cueSize != Cue.DIMEN_UNSET) {
availableWidth = (int) (availableWidth * cueSize);
}
if (availableWidth <= 0) { if (availableWidth <= 0) {
Log.w(TAG, "Skipped drawing subtitle cue (insufficient space)"); Log.w(TAG, "Skipped drawing subtitle cue (insufficient space)");
return; return;
} }
Alignment layoutAlignment = cueAlignment == null ? Alignment.ALIGN_CENTER : cueAlignment; Alignment textAlignment = cueTextAlignment == null ? Alignment.ALIGN_CENTER : cueTextAlignment;
textLayout = new StaticLayout(cueText, textPaint, availableWidth, layoutAlignment, spacingMult, textLayout = new StaticLayout(cueText, textPaint, availableWidth, textAlignment, spacingMult,
spacingAdd, true); spacingAdd, true);
int textHeight = textLayout.getHeight(); int textHeight = textLayout.getHeight();
int textWidth = 0; int textWidth = 0;
int lineCount = textLayout.getLineCount(); int lineCount = textLayout.getLineCount();
@ -202,14 +220,13 @@ import android.util.Log;
int textLeft; int textLeft;
int textRight; int textRight;
if (cue.position != Cue.UNSET_VALUE) { if (cuePosition != Cue.DIMEN_UNSET) {
if (cue.alignment == Alignment.ALIGN_OPPOSITE) { int anchorPosition = Math.round(parentWidth * cuePosition) + parentLeft;
textRight = (parentWidth * cue.position) / 100 + parentLeft; textLeft = cuePositionAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textWidth
textLeft = Math.max(textRight - textWidth, parentLeft); : cuePositionAnchor == Cue.ANCHOR_TYPE_MIDDLE ? (anchorPosition * 2 - textWidth) / 2
} else { : anchorPosition;
textLeft = (parentWidth * cue.position) / 100 + parentLeft; textLeft = Math.max(textLeft, parentLeft);
textRight = Math.min(textLeft + textWidth, parentRight); textRight = Math.min(textLeft + textWidth, parentRight);
}
} else { } else {
textLeft = (parentWidth - textWidth) / 2; textLeft = (parentWidth - textWidth) / 2;
textRight = textLeft + textWidth; textRight = textLeft + textWidth;
@ -217,12 +234,29 @@ import android.util.Log;
int textTop; int textTop;
int textBottom; int textBottom;
if (cue.line != Cue.UNSET_VALUE) { if (cueLine != Cue.DIMEN_UNSET) {
textTop = (parentHeight * cue.line) / 100 + parentTop; int anchorPosition;
if (cueLineType == Cue.LINE_TYPE_FRACTION) {
anchorPosition = Math.round(parentHeight * cueLine) + parentTop;
} else {
// cueLineType == Cue.LINE_TYPE_NUMBER
int firstLineHeight = textLayout.getLineBottom(0) - textLayout.getLineTop(0);
if (cueLine >= 0) {
anchorPosition = Math.round(cueLine * firstLineHeight) + parentTop;
} else {
anchorPosition = Math.round(cueLine * firstLineHeight) + parentBottom;
}
}
textTop = cueLineAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textHeight
: cueLineAnchor == Cue.ANCHOR_TYPE_MIDDLE ? (anchorPosition * 2 - textHeight) / 2
: anchorPosition;
textBottom = textTop + textHeight; textBottom = textTop + textHeight;
if (textBottom > parentBottom) { if (textBottom > parentBottom) {
textTop = parentBottom - textHeight; textTop = parentBottom - textHeight;
textBottom = parentBottom; textBottom = parentBottom;
} else if (textTop < parentTop) {
textTop = parentTop;
textBottom = parentTop + textHeight;
} }
} else { } else {
textTop = parentBottom - textHeight - (int) (parentHeight * bottomPaddingFraction); textTop = parentBottom - textHeight - (int) (parentHeight * bottomPaddingFraction);
@ -232,7 +266,7 @@ import android.util.Log;
textWidth = textRight - textLeft; textWidth = textRight - textLeft;
// Update the derived drawing variables. // Update the derived drawing variables.
this.textLayout = new StaticLayout(cueText, textPaint, textWidth, layoutAlignment, spacingMult, this.textLayout = new StaticLayout(cueText, textPaint, textWidth, textAlignment, spacingMult,
spacingAdd, true); spacingAdd, true);
this.textLeft = textLeft; this.textLeft = textLeft;
this.textTop = textTop; this.textTop = textTop;

View File

@ -38,7 +38,7 @@ public final class SubtitleLayout extends View {
public static final float DEFAULT_TEXT_SIZE_FRACTION = 0.0533f; public static final float DEFAULT_TEXT_SIZE_FRACTION = 0.0533f;
/** /**
* The default bottom padding to apply when {@link Cue#line} is {@link Cue#UNSET_VALUE}, as a * The default bottom padding to apply when {@link Cue#line} is {@link Cue#DIMEN_UNSET}, as a
* fraction of the viewport height. * fraction of the viewport height.
* *
* @see #setBottomPaddingFraction(float) * @see #setBottomPaddingFraction(float)
@ -174,7 +174,7 @@ public final class SubtitleLayout extends View {
} }
/** /**
* Sets the bottom padding fraction to apply when {@link Cue#line} is {@link Cue#UNSET_VALUE}, * Sets the bottom padding fraction to apply when {@link Cue#line} is {@link Cue#DIMEN_UNSET},
* as a fraction of the view's remaining height after its top and bottom padding have been * as a fraction of the view's remaining height after its top and bottom padding have been
* subtracted. * subtracted.
* <p> * <p>

View File

@ -28,16 +28,17 @@ import android.text.Layout.Alignment;
public final long endTime; public final long endTime;
public WebvttCue(CharSequence text) { public WebvttCue(CharSequence text) {
this(Cue.UNSET_VALUE, Cue.UNSET_VALUE, text); this(0, 0, text);
} }
public WebvttCue(long startTime, long endTime, CharSequence text) { public WebvttCue(long startTime, long endTime, CharSequence text) {
this(startTime, endTime, text, Cue.UNSET_VALUE, Cue.UNSET_VALUE, null, Cue.UNSET_VALUE); this(startTime, endTime, text, null, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET,
Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET);
} }
public WebvttCue(long startTime, long endTime, CharSequence text, int line, int position, public WebvttCue(long startTime, long endTime, CharSequence text, Alignment textAlignment,
Alignment alignment, int size) { float line, int lineType, int lineAnchor, float position, int positionAnchor, float width) {
super(text, line, position, alignment, size); super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, width);
this.startTime = startTime; this.startTime = startTime;
this.endTime = endTime; this.endTime = endTime;
} }
@ -49,7 +50,7 @@ import android.text.Layout.Alignment;
* @return True if this cue should be placed in the default position; false otherwise. * @return True if this cue should be placed in the default position; false otherwise.
*/ */
public boolean isNormalCue() { public boolean isNormalCue() {
return (line == UNSET_VALUE && position == UNSET_VALUE); return (line == DIMEN_UNSET && position == DIMEN_UNSET);
} }
} }

View File

@ -24,7 +24,6 @@ import com.google.android.exoplayer.util.MimeTypes;
import android.text.Html; import android.text.Html;
import android.text.Layout.Alignment; import android.text.Layout.Alignment;
import android.util.Log; import android.util.Log;
import android.util.Pair;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@ -43,34 +42,15 @@ public final class WebvttParser implements SubtitleParser {
private static final String TAG = "WebvttParser"; private static final String TAG = "WebvttParser";
private static final String WEBVTT_FILE_HEADER_STRING = "^\uFEFF?WEBVTT((\u0020|\u0009).*)?$"; private static final Pattern HEADER = Pattern.compile("^\uFEFF?WEBVTT((\u0020|\u0009).*)?$");
private static final Pattern WEBVTT_FILE_HEADER = private static final Pattern COMMENT_BLOCK = Pattern.compile("^NOTE((\u0020|\u0009).*)?$");
Pattern.compile(WEBVTT_FILE_HEADER_STRING); private static final Pattern METADATA_HEADER = Pattern.compile("\\S*[:=]\\S*");
private static final Pattern CUE_IDENTIFIER = Pattern.compile("^(?!.*(-->)).*$");
private static final String WEBVTT_COMMENT_BLOCK_STRING = "^NOTE((\u0020|\u0009).*)?$"; private static final Pattern TIMESTAMP = Pattern.compile("(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}");
private static final Pattern WEBVTT_COMMENT_BLOCK = private static final Pattern CUE_SETTING = Pattern.compile("\\S*:\\S*");
Pattern.compile(WEBVTT_COMMENT_BLOCK_STRING);
private static final String WEBVTT_METADATA_HEADER_STRING = "\\S*[:=]\\S*";
private static final Pattern WEBVTT_METADATA_HEADER =
Pattern.compile(WEBVTT_METADATA_HEADER_STRING);
private static final String WEBVTT_CUE_IDENTIFIER_STRING = "^(?!.*(-->)).*$";
private static final Pattern WEBVTT_CUE_IDENTIFIER =
Pattern.compile(WEBVTT_CUE_IDENTIFIER_STRING);
private static final String WEBVTT_TIMESTAMP_STRING = "(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}";
private static final Pattern WEBVTT_TIMESTAMP = Pattern.compile(WEBVTT_TIMESTAMP_STRING);
private static final String WEBVTT_CUE_SETTING_STRING = "\\S*:\\S*";
private static final Pattern WEBVTT_CUE_SETTING = Pattern.compile(WEBVTT_CUE_SETTING_STRING);
private static final String WEBVTT_PERCENTAGE_NUMBER_STRING = "^([0-9]+|[0-9]+\\.[0-9]+)$";
private static final String NON_NUMERIC_STRING = ".*[^0-9].*";
private final PositionHolder positionHolder;
private final StringBuilder textBuilder; private final StringBuilder textBuilder;
private final boolean strictParsing; private final boolean strictParsing;
/** /**
@ -88,6 +68,7 @@ public final class WebvttParser implements SubtitleParser {
*/ */
public WebvttParser(boolean strictParsing) { public WebvttParser(boolean strictParsing) {
this.strictParsing = strictParsing; this.strictParsing = strictParsing;
positionHolder = new PositionHolder();
textBuilder = new StringBuilder(); textBuilder = new StringBuilder();
} }
@ -100,7 +81,7 @@ public final class WebvttParser implements SubtitleParser {
// file should start with "WEBVTT" // file should start with "WEBVTT"
line = webvttData.readLine(); line = webvttData.readLine();
if (line == null || !WEBVTT_FILE_HEADER.matcher(line).matches()) { if (line == null || !HEADER.matcher(line).matches()) {
throw new ParserException("Expected WEBVTT. Got " + line); throw new ParserException("Expected WEBVTT. Got " + line);
} }
@ -116,7 +97,7 @@ public final class WebvttParser implements SubtitleParser {
} }
if (strictParsing) { if (strictParsing) {
Matcher matcher = WEBVTT_METADATA_HEADER.matcher(line); Matcher matcher = METADATA_HEADER.matcher(line);
if (!matcher.find()) { if (!matcher.find()) {
throw new ParserException("Unexpected line: " + line); throw new ParserException("Unexpected line: " + line);
} }
@ -126,38 +107,45 @@ public final class WebvttParser implements SubtitleParser {
// process the cues and text // process the cues and text
while ((line = webvttData.readLine()) != null) { while ((line = webvttData.readLine()) != null) {
// parse webvtt comment block in case it is present // parse webvtt comment block in case it is present
Matcher matcher = WEBVTT_COMMENT_BLOCK.matcher(line); Matcher matcher = COMMENT_BLOCK.matcher(line);
if (matcher.find()) { if (matcher.find()) {
// read lines until finding an empty one (webvtt line terminator: CRLF, or LF or CR) // read lines until finding an empty one (webvtt line terminator: CRLF, or LF or CR)
while (((line = webvttData.readLine()) != null) && (!line.isEmpty())) { while ((line = webvttData.readLine()) != null && !line.isEmpty()) {
// just ignoring comment text // ignore comment text
} }
continue; continue;
} }
// parse the cue identifier (if present) { // parse the cue identifier (if present) {
matcher = WEBVTT_CUE_IDENTIFIER.matcher(line); matcher = CUE_IDENTIFIER.matcher(line);
if (matcher.find()) { if (matcher.find()) {
// ignore the identifier (we currently don't use it) and read the next line // ignore the identifier (we currently don't use it) and read the next line
line = webvttData.readLine(); line = webvttData.readLine();
if (line == null) {
// end of file
break;
}
} }
long startTime = Cue.UNSET_VALUE; long cueStartTime;
long endTime = Cue.UNSET_VALUE; long cueEndTime;
CharSequence text = null; CharSequence cueText;
int lineNum = Cue.UNSET_VALUE; Alignment cueTextAlignment = null;
int position = Cue.UNSET_VALUE; float cueLine = Cue.DIMEN_UNSET;
Alignment alignment = null; int cueLineType = Cue.TYPE_UNSET;
int size = Cue.UNSET_VALUE; int cueLineAnchor = Cue.TYPE_UNSET;
float cuePosition = Cue.DIMEN_UNSET;
int cuePositionAnchor = Cue.TYPE_UNSET;
float cueWidth = Cue.DIMEN_UNSET;
// parse the cue timestamps // parse the cue timestamps
matcher = WEBVTT_TIMESTAMP.matcher(line); matcher = TIMESTAMP.matcher(line);
// parse start timestamp // parse start timestamp
if (!matcher.find()) { if (!matcher.find()) {
throw new ParserException("Expected cue start time: " + line); throw new ParserException("Expected cue start time: " + line);
} else { } else {
startTime = parseTimestampUs(matcher.group()); cueStartTime = parseTimestampUs(matcher.group());
} }
// parse end timestamp // parse end timestamp
@ -166,12 +154,12 @@ public final class WebvttParser implements SubtitleParser {
throw new ParserException("Expected cue end time: " + line); throw new ParserException("Expected cue end time: " + line);
} else { } else {
endTimeString = matcher.group(); endTimeString = matcher.group();
endTime = parseTimestampUs(endTimeString); cueEndTime = parseTimestampUs(endTimeString);
} }
// parse the (optional) cue setting list // parse the (optional) cue setting list
line = line.substring(line.indexOf(endTimeString) + endTimeString.length()); line = line.substring(line.indexOf(endTimeString) + endTimeString.length());
matcher = WEBVTT_CUE_SETTING.matcher(line); matcher = CUE_SETTING.matcher(line);
while (matcher.find()) { while (matcher.find()) {
String match = matcher.group(); String match = matcher.group();
String[] parts = match.split(":", 2); String[] parts = match.split(":", 2);
@ -180,52 +168,44 @@ public final class WebvttParser implements SubtitleParser {
try { try {
if ("line".equals(name)) { if ("line".equals(name)) {
Pair<String, Alignment> lineMetadata = parseLinePositionAttributes(value); parseLineAttribute(value, positionHolder);
value = lineMetadata.first; cueLine = positionHolder.position;
if (value.endsWith("%")) { cueLineType = positionHolder.lineType;
lineNum = parseIntPercentage(value); cueLineAnchor = positionHolder.positionAnchor;
} else {
// Following WebVTT spec, line number can be a negative number
int sign = 1;
if (value.startsWith("-") && value.length() > 1) {
sign = -1;
value = value.substring(1);
}
if (value.matches(NON_NUMERIC_STRING)) {
Log.w(TAG, "Invalid line value: " + value);
} else {
lineNum = sign * Integer.parseInt(value);
}
}
} else if ("align".equals(name)) { } else if ("align".equals(name)) {
// TODO: handle for RTL languages cueTextAlignment = parseTextAlignment(value);
alignment = parseAlignment(value);
} else if ("position".equals(name)) { } else if ("position".equals(name)) {
Pair<String, Alignment> lineMetadata = parseLinePositionAttributes(value); parsePositionAttribute(value, positionHolder);
value = lineMetadata.first; cuePosition = positionHolder.position;
position = parseIntPercentage(value); cuePositionAnchor = positionHolder.positionAnchor;
} else if ("size".equals(name)) { } else if ("size".equals(name)) {
size = parseIntPercentage(value); cueWidth = parsePercentage(value);
} else { } else {
Log.w(TAG, "Unknown cue setting " + name + ":" + value); Log.w(TAG, "Unknown cue setting " + name + ":" + value);
} }
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
Log.w(TAG, name + " contains an invalid value " + value, e); Log.w(TAG, e.getMessage() + ": " + match);
} }
} }
if (cuePosition != Cue.DIMEN_UNSET && cuePositionAnchor == Cue.TYPE_UNSET) {
// Computed position alignment should be derived from the text alignment if it has not been
// set explicitly.
cuePositionAnchor = alignmentToAnchor(cueTextAlignment);
}
// parse text // parse text
textBuilder.setLength(0); textBuilder.setLength(0);
while (((line = webvttData.readLine()) != null) && (!line.isEmpty())) { while ((line = webvttData.readLine()) != null && !line.isEmpty()) {
if (textBuilder.length() > 0) { if (textBuilder.length() > 0) {
textBuilder.append("<br>"); textBuilder.append("<br>");
} }
textBuilder.append(line.trim()); textBuilder.append(line.trim());
} }
text = Html.fromHtml(textBuilder.toString()); cueText = Html.fromHtml(textBuilder.toString());
WebvttCue cue = new WebvttCue(startTime, endTime, text, lineNum, position, alignment, size); WebvttCue cue = new WebvttCue(cueStartTime, cueEndTime, cueText, cueTextAlignment, cueLine,
cueLineType, cueLineAnchor, cuePosition, cuePositionAnchor, cueWidth);
subtitles.add(cue); subtitles.add(cue);
} }
@ -237,67 +217,116 @@ public final class WebvttParser implements SubtitleParser {
return MimeTypes.TEXT_VTT.equals(mimeType); return MimeTypes.TEXT_VTT.equals(mimeType);
} }
private static int parseIntPercentage(String s) throws NumberFormatException {
if (!s.endsWith("%")) {
throw new NumberFormatException(s + " doesn't end with '%'");
}
s = s.substring(0, s.length() - 1);
if (!s.matches(WEBVTT_PERCENTAGE_NUMBER_STRING)) {
throw new NumberFormatException(s + " contains an invalid character");
}
int value = Math.round(Float.parseFloat(s));
if (value < 0 || value > 100) {
throw new NumberFormatException(value + " is out of range [0-100]");
}
return value;
}
private static long parseTimestampUs(String s) throws NumberFormatException { private static long parseTimestampUs(String s) throws NumberFormatException {
if (!s.matches(WEBVTT_TIMESTAMP_STRING)) {
throw new NumberFormatException("has invalid format");
}
String[] parts = s.split("\\.", 2);
long value = 0; long value = 0;
for (String group : parts[0].split(":")) { String[] parts = s.split("\\.", 2);
value = value * 60 + Long.parseLong(group); String[] subparts = parts[0].split(":");
for (int i = 0; i < subparts.length; i++) {
value = value * 60 + Long.parseLong(subparts[i]);
} }
return (value * 1000 + Long.parseLong(parts[1])) * 1000; return (value * 1000 + Long.parseLong(parts[1])) * 1000;
} }
private static Pair<String, Alignment> parseLinePositionAttributes(String s) { private static void parseLineAttribute(String s, PositionHolder out)
String value; throws NumberFormatException {
Alignment alignment = null; int lineAnchor;
int commaPosition = s.indexOf(",");
int commaPos; if (commaPosition != -1) {
if ((commaPos = s.indexOf(",")) > 0 && commaPos < s.length() - 1) { lineAnchor = parsePositionAnchor(s.substring(commaPosition + 1));
alignment = parseAlignment(s.substring(commaPos + 1)); s = s.substring(0, commaPosition);
value = s.substring(0, commaPos);
} else { } else {
value = s; lineAnchor = Cue.TYPE_UNSET;
} }
float line;
return new Pair<String, Alignment>(value, alignment); int lineType;
} if (s.endsWith("%")) {
line = parsePercentage(s);
private static Alignment parseAlignment(String s) { lineType = Cue.LINE_TYPE_FRACTION;
Alignment alignment = null;
if ("start".equals(s)) {
alignment = Alignment.ALIGN_NORMAL;
} else if ("middle".equals(s)) {
alignment = Alignment.ALIGN_CENTER;
} else if ("end".equals(s)) {
alignment = Alignment.ALIGN_OPPOSITE;
} else if ("left".equals(s)) {
alignment = Alignment.ALIGN_NORMAL;
} else if ("right".equals(s)) {
alignment = Alignment.ALIGN_OPPOSITE;
} else { } else {
Log.w(TAG, "Invalid align value: " + s); line = Integer.parseInt(s);
lineType = Cue.LINE_TYPE_NUMBER;
} }
return alignment; out.position = line;
out.positionAnchor = lineAnchor;
out.lineType = lineType;
}
private static void parsePositionAttribute(String s, PositionHolder out)
throws NumberFormatException {
int positionAnchor;
int commaPosition = s.indexOf(",");
if (commaPosition != -1) {
positionAnchor = parsePositionAnchor(s.substring(commaPosition + 1));
s = s.substring(0, commaPosition);
} else {
positionAnchor = Cue.TYPE_UNSET;
}
out.position = parsePercentage(s);
out.positionAnchor = positionAnchor;
out.lineType = Cue.TYPE_UNSET;
}
private static float parsePercentage(String s) throws NumberFormatException {
if (!s.endsWith("%")) {
throw new NumberFormatException("Percentages must end with %");
}
s = s.substring(0, s.length() - 1);
return Float.parseFloat(s) / 100;
}
private static int parsePositionAnchor(String s) {
switch (s) {
case "start":
return Cue.ANCHOR_TYPE_START;
case "middle":
return Cue.ANCHOR_TYPE_MIDDLE;
case "end":
return Cue.ANCHOR_TYPE_END;
default:
Log.w(TAG, "Invalid anchor value: " + s);
return Cue.TYPE_UNSET;
}
}
private static Alignment parseTextAlignment(String s) {
switch (s) {
case "start":
case "left":
return Alignment.ALIGN_NORMAL;
case "middle":
return Alignment.ALIGN_CENTER;
case "end":
case "right":
return Alignment.ALIGN_OPPOSITE;
default:
Log.w(TAG, "Invalid alignment value: " + s);
return null;
}
}
private static int alignmentToAnchor(Alignment alignment) {
if (alignment == null) {
return Cue.TYPE_UNSET;
}
switch (alignment) {
case ALIGN_NORMAL:
return Cue.ANCHOR_TYPE_START;
case ALIGN_CENTER:
return Cue.ANCHOR_TYPE_MIDDLE;
case ALIGN_OPPOSITE:
return Cue.ANCHOR_TYPE_END;
default:
Log.w(TAG, "Unrecognized alignment: " + alignment);
return Cue.ANCHOR_TYPE_START;
}
}
private static final class PositionHolder {
public float position;
public int positionAnchor;
public int lineType;
} }
} }