Add support to CSS in WebVTT files

This CL adds the support of CSS styling in Cues through id and "universal" cue selector.
The more sophisticated selectors will be left for later, because they requier a bit more
complex logic. Also narrowed a little bit the responsibilities of the WebvttCueParser to
move some to the WebvttParser.
-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=119547731
This commit is contained in:
aquilescanta 2016-04-11 10:54:31 -07:00 committed by Oliver Woodman
parent 087cf9546f
commit efe76def89
8 changed files with 258 additions and 62 deletions

View File

@ -0,0 +1,23 @@
WEBVTT
STYLE
::cue {
background-color: green;
color: papayawhip;
}
/* Style blocks cannot use blank lines nor "dash dash greater than" */
NOTE comment blocks can be used between style blocks.
STYLE
::cue(2) {
color: peachpuff;
}
1
00:00.000 --> 00:01.234
This is the first subtitle.
2
00:02.345 --> 00:03.456
This is the second subtitle.

View File

@ -21,6 +21,9 @@ import com.google.android.exoplayer.text.Cue;
import android.test.InstrumentationTestCase;
import android.text.Layout.Alignment;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import java.io.IOException;
import java.util.List;
@ -36,6 +39,7 @@ public class WebvttParserTest extends InstrumentationTestCase {
private static final String WITH_POSITIONING_FILE = "webvtt/with_positioning";
private static final String WITH_BAD_CUE_HEADER_FILE = "webvtt/with_bad_cue_header";
private static final String WITH_TAGS_FILE = "webvtt/with_tags";
private static final String WITH_CSS_STYLES = "webvtt/with_css_styles";
private static final String EMPTY_FILE = "webvtt/empty";
public void testParseEmpty() throws IOException {
@ -54,10 +58,10 @@ public class WebvttParserTest extends InstrumentationTestCase {
byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_FILE);
WebvttSubtitle subtitle = parser.decode(bytes, bytes.length);
// test event count
// Test event count.
assertEquals(4, subtitle.getEventTimeCount());
// test cues
// Test cues.
assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
}
@ -67,10 +71,10 @@ public class WebvttParserTest extends InstrumentationTestCase {
byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_WITH_IDS_FILE);
WebvttSubtitle subtitle = parser.decode(bytes, bytes.length);
// test event count
// Test event count.
assertEquals(4, subtitle.getEventTimeCount());
// test cues
// Test cues.
assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
}
@ -93,10 +97,10 @@ public class WebvttParserTest extends InstrumentationTestCase {
byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_TAGS_FILE);
WebvttSubtitle subtitle = parser.decode(bytes, bytes.length);
// test event count
// Test event count.
assertEquals(8, subtitle.getEventTimeCount());
// test cues
// Test cues.
assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.");
assertCue(subtitle, 4, 4000000, 5000000, "This is the third subtitle.");
@ -108,10 +112,10 @@ public class WebvttParserTest extends InstrumentationTestCase {
byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_POSITIONING_FILE);
WebvttSubtitle subtitle = parser.decode(bytes, bytes.length);
// test event count
// Test event count.
assertEquals(12, subtitle.getEventTimeCount());
// test cues
// Test cues.
assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.", Alignment.ALIGN_NORMAL,
Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, 0.1f, Cue.ANCHOR_TYPE_START, 0.35f);
assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.",
@ -136,13 +140,34 @@ public class WebvttParserTest extends InstrumentationTestCase {
byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_BAD_CUE_HEADER_FILE);
WebvttSubtitle subtitle = parser.decode(bytes, bytes.length);
// test event count
// Test event count.
assertEquals(4, subtitle.getEventTimeCount());
// test cues
// Test cues.
assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.");
assertCue(subtitle, 2, 4000000, 5000000, "This is the third subtitle.");
}
public void testWebvttWithCssStyle() throws IOException {
WebvttParser parser = new WebvttParser();
byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_CSS_STYLES);
WebvttSubtitle subtitle = parser.decode(bytes, bytes.length);
// 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.");
Cue cue1 = subtitle.getCues(0).get(0);
Cue cue2 = subtitle.getCues(2345000).get(0);
Spanned s1 = (Spanned) cue1.text;
Spanned s2 = (Spanned) cue2.text;
assertEquals(1, s1.getSpans(0, s1.length(), ForegroundColorSpan.class).length);
assertEquals(1, s1.getSpans(0, s1.length(), BackgroundColorSpan.class).length);
assertEquals(2, s2.getSpans(0, s2.length(), ForegroundColorSpan.class).length);
}
private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs,
int endTimeUs, String text) {
@ -157,7 +182,7 @@ public class WebvttParserTest extends InstrumentationTestCase {
assertEquals(endTimeUs, subtitle.getEventTime(eventTimeIndex + 1));
List<Cue> cues = subtitle.getCues(subtitle.getEventTime(eventTimeIndex));
assertEquals(1, cues.size());
// Assert cue properties
// Assert cue properties.
Cue cue = cues.get(0);
assertEquals(text, cue.text.toString());
assertEquals(textAlignment, cue.textAlignment);

View File

@ -25,7 +25,6 @@ import com.google.android.exoplayer.extractor.PositionHolder;
import com.google.android.exoplayer.extractor.SeekMap;
import com.google.android.exoplayer.extractor.TrackOutput;
import com.google.android.exoplayer.extractor.ts.PtsTimestampAdjuster;
import com.google.android.exoplayer.text.webvtt.WebvttCueParser;
import com.google.android.exoplayer.text.webvtt.WebvttParserUtil;
import com.google.android.exoplayer.util.MimeTypes;
import com.google.android.exoplayer.util.ParsableByteArray;
@ -145,7 +144,7 @@ import java.util.regex.Pattern;
}
// Find the first cue header and parse the start time.
Matcher cueHeaderMatcher = WebvttCueParser.findNextCueHeader(webvttData);
Matcher cueHeaderMatcher = WebvttParserUtil.findNextCueHeader(webvttData);
if (cueHeaderMatcher == null) {
// No cues found. Don't output a sample, but still output a corresponding track.
buildTrackOutput(0);

View File

@ -54,6 +54,7 @@ import java.util.Map;
* @param styleMap The map that contains styles accessible by selector.
*/
public void parseBlock(ParsableByteArray input, Map<String, WebvttCssStyle> styleMap) {
stringBuilder.setLength(0);
int initialInputPosition = input.getPosition();
skipStyleBlock(input);
styleInput.reset(input.data, input.getPosition());
@ -97,8 +98,8 @@ import java.util.Map;
* ::cue(v[voice="Someone"])
*
* @param input From which the selector is obtained.
* @return A string containing the target, empty string if targets all cues and null if an error
* was encountered.
* @return A string containing the target, {@link WebvttCue#UNIVERSAL_CUE_ID} if targets all cues
* and null if an error was encountered.
*/
private static String parseSelector(ParsableByteArray input, StringBuilder stringBuilder) {
skipWhitespaceAndComments(input);
@ -116,7 +117,7 @@ import java.util.Map;
}
if ("{".equals(token)) {
input.setPosition(position);
return "";
return WebvttCue.UNIVERSAL_CUE_ID;
}
String target = null;
if ("(".equals(token)) {

View File

@ -18,13 +18,31 @@ package com.google.android.exoplayer.text.webvtt;
import com.google.android.exoplayer.text.Cue;
import android.text.Layout.Alignment;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.AlignmentSpan;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import android.util.Log;
import java.util.Collections;
import java.util.Map;
/**
* A representation of a WebVTT cue.
*/
/* package */ final class WebvttCue extends Cue {
public static final String UNIVERSAL_CUE_ID = "";
public final String id;
public final long startTime;
public final long endTime;
@ -33,13 +51,15 @@ import android.util.Log;
}
public WebvttCue(long startTime, long endTime, CharSequence text) {
this(startTime, endTime, text, null, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET,
this(null, 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, Alignment textAlignment,
float line, int lineType, int lineAnchor, float position, int positionAnchor, float width) {
public WebvttCue(String id, long startTime, long endTime, CharSequence text,
Alignment textAlignment, float line, int lineType, int lineAnchor, float position,
int positionAnchor, float width) {
super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, width);
this.id = id;
this.startTime = startTime;
this.endTime = endTime;
}
@ -62,9 +82,10 @@ import android.util.Log;
private static final String TAG = "WebvttCueBuilder";
private String id;
private long startTime;
private long endTime;
private CharSequence text;
private SpannableStringBuilder text;
private Alignment textAlignment;
private float line;
private int lineType;
@ -92,16 +113,28 @@ import android.util.Log;
width = Cue.DIMEN_UNSET;
}
// Construction methods
// Construction methods.
public WebvttCue build() {
return build(Collections.<String, WebvttCssStyle>emptyMap());
}
public WebvttCue build(Map<String, WebvttCssStyle> styleMap) {
// TODO: Add support for inner spans.
maybeApplyStyleToText(styleMap.get(UNIVERSAL_CUE_ID), 0, text.length());
maybeApplyStyleToText(styleMap.get(id), 0, text.length());
if (position != Cue.DIMEN_UNSET && positionAnchor == Cue.TYPE_UNSET) {
derivePositionAnchorFromAlignment();
}
return new WebvttCue(startTime, endTime, text, textAlignment, line, lineType, lineAnchor,
return new WebvttCue(id, startTime, endTime, text, textAlignment, line, lineType, lineAnchor,
position, positionAnchor, width);
}
public Builder setId(String id) {
this.id = id;
return this;
}
public Builder setStartTime(long time) {
startTime = time;
return this;
@ -112,7 +145,7 @@ import android.util.Log;
return this;
}
public Builder setText(CharSequence aText) {
public Builder setText(SpannableStringBuilder aText) {
text = aText;
return this;
}
@ -175,6 +208,54 @@ import android.util.Log;
return this;
}
private void maybeApplyStyleToText(WebvttCssStyle style, int start, int end) {
if (style == null) {
return;
}
if (style.getStyle() != WebvttCssStyle.UNSPECIFIED) {
text.setSpan(new StyleSpan(style.getStyle()), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.isLinethrough()) {
text.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.isUnderline()) {
text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.hasFontColor()) {
text.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.hasBackgroundColor()) {
text.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.getFontFamily() != null) {
text.setSpan(new TypefaceSpan(style.getFontFamily()), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.getTextAlign() != null) {
text.setSpan(new AlignmentSpan.Standard(style.getTextAlign()), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (style.getFontSizeUnit() != WebvttCssStyle.UNSPECIFIED) {
switch (style.getFontSizeUnit()) {
case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL:
text.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
break;
case WebvttCssStyle.FONT_SIZE_UNIT_EM:
text.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
break;
case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT:
text.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
break;
}
}
}
}
}

View File

@ -33,12 +33,11 @@ import java.util.regex.Pattern;
/**
* Parser for WebVTT cues. (https://w3c.github.io/webvtt/#cues)
*/
public final class WebvttCueParser {
/* package */ final class WebvttCueParser {
public static final Pattern CUE_HEADER_PATTERN = Pattern
.compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$");
private static final Pattern COMMENT = Pattern.compile("^NOTE((\u0020|\u0009).*)?$");
private static final Pattern CUE_SETTING_PATTERN = Pattern.compile("(\\S+?):(\\S+)");
private static final char CHAR_LESS_THAN = '<';
@ -71,7 +70,7 @@ public final class WebvttCueParser {
public WebvttCueParser() {
textBuilder = new StringBuilder();
}
/**
* Parses the next valid WebVTT cue in a parsable array, including timestamps, settings and text.
*
@ -79,11 +78,20 @@ public final class WebvttCueParser {
* @param builder Builder for WebVTT Cues.
* @return True if a valid Cue was found, false otherwise.
*/
/* package */ boolean parseNextValidCue(ParsableByteArray webvttData, WebvttCue.Builder builder) {
Matcher cueHeaderMatcher;
while ((cueHeaderMatcher = findNextCueHeader(webvttData)) != null) {
if (parseCue(cueHeaderMatcher, webvttData, builder, textBuilder)) {
return true;
/* package */ boolean parseCue(ParsableByteArray webvttData, WebvttCue.Builder builder) {
String firstLine = webvttData.readLine();
Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(firstLine);
if (cueHeaderMatcher.matches()) {
// We have found the timestamps in the first line. No id present.
return parseCue(cueHeaderMatcher, webvttData, builder, textBuilder);
} else {
// The first line is not the timestamps, but could be the cue id.
String secondLine = webvttData.readLine();
cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(secondLine);
if (cueHeaderMatcher.matches()) {
// We can do the rest of the parsing, including the id.
builder.setId(firstLine.trim());
return parseCue(cueHeaderMatcher, webvttData, builder, textBuilder);
}
}
return false;
@ -120,30 +128,6 @@ public final class WebvttCueParser {
}
}
/**
* Reads lines up to and including the next WebVTT cue header.
*
* @param input The input from which lines should be read.
* @return A {@link Matcher} for the WebVTT cue header, or null if the end of the input was
* reached without a cue header being found. In the case that a cue header is found, groups 1,
* 2 and 3 of the returned matcher contain the start time, end time and settings list.
*/
public static Matcher findNextCueHeader(ParsableByteArray input) {
String line;
while ((line = input.readLine()) != null) {
if (COMMENT.matcher(line).matches()) {
// Skip until the end of the comment block.
while ((line = input.readLine()) != null && !line.isEmpty()) {}
} else {
Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(line);
if (cueHeaderMatcher.matches()) {
return cueHeaderMatcher;
}
}
}
return null;
}
/**
* Parses the text payload of a WebVTT Cue and applies modifications on {@link WebvttCue.Builder}.
*

View File

@ -22,6 +22,7 @@ import com.google.android.exoplayer.util.ParsableByteArray;
import android.text.TextUtils;
import java.util.ArrayList;
import java.util.HashMap;
/**
* A simple WebVTT parser.
@ -30,32 +31,88 @@ import java.util.ArrayList;
*/
public final class WebvttParser extends SubtitleParser {
private static final int NO_EVENT_FOUND = -1;
private static final int END_OF_FILE_FOUND = 0;
private static final int COMMENT_FOUND = 1;
private static final int STYLE_BLOCK_FOUND = 2;
private static final int CUE_FOUND = 3;
private static final String COMMENT_START = "NOTE";
private static final String STYLE_START = "STYLE";
private final WebvttCueParser cueParser;
private final ParsableByteArray parsableWebvttData;
private final WebvttCue.Builder webvttCueBuilder;
private final CssParser cssParser;
private final HashMap<String, WebvttCssStyle> styleMap;
public WebvttParser() {
cueParser = new WebvttCueParser();
parsableWebvttData = new ParsableByteArray();
webvttCueBuilder = new WebvttCue.Builder();
cssParser = new CssParser();
styleMap = new HashMap<>();
}
@Override
protected final WebvttSubtitle decode(byte[] bytes, int length) throws ParserException {
protected WebvttSubtitle decode(byte[] bytes, int length) throws ParserException {
parsableWebvttData.reset(bytes, length);
webvttCueBuilder.reset(); // In case a previous parse run failed with a ParserException.
// Initialization for consistent starting state.
webvttCueBuilder.reset();
styleMap.clear();
// Validate the first line of the header, and skip the remainder.
WebvttParserUtil.validateWebvttHeaderLine(parsableWebvttData);
while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {}
// Extract Cues
int eventFound;
ArrayList<WebvttCue> subtitles = new ArrayList<>();
while (cueParser.parseNextValidCue(parsableWebvttData, webvttCueBuilder)) {
subtitles.add(webvttCueBuilder.build());
webvttCueBuilder.reset();
while ((eventFound = getNextEvent(parsableWebvttData)) != END_OF_FILE_FOUND) {
if (eventFound == COMMENT_FOUND) {
skipComment(parsableWebvttData);
} else if (eventFound == STYLE_BLOCK_FOUND) {
if (!subtitles.isEmpty()) {
throw new ParserException("A style block was found after the first cue.");
}
parsableWebvttData.readLine(); // Consume the "STYLE" header.
cssParser.parseBlock(parsableWebvttData, styleMap);
} else if (eventFound == CUE_FOUND) {
if (cueParser.parseCue(parsableWebvttData, webvttCueBuilder)) {
subtitles.add(webvttCueBuilder.build(styleMap));
webvttCueBuilder.reset();
}
}
}
return new WebvttSubtitle(subtitles);
}
/**
* Positions the input right before the next event, and returns the kind of event found. Does not
* consume any data from such event, if any.
*
* @return The kind of event found.
*/
private static int getNextEvent(ParsableByteArray parsableWebvttData) {
int foundEvent = NO_EVENT_FOUND;
int currentInputPosition = 0;
while (foundEvent == NO_EVENT_FOUND) {
currentInputPosition = parsableWebvttData.getPosition();
String line = parsableWebvttData.readLine();
if (line == null) {
foundEvent = END_OF_FILE_FOUND;
} else if (STYLE_START.equals(line)) {
foundEvent = STYLE_BLOCK_FOUND;
} else if (COMMENT_START.startsWith(line)) {
foundEvent = COMMENT_FOUND;
} else {
foundEvent = CUE_FOUND;
}
}
parsableWebvttData.setPosition(currentInputPosition);
return foundEvent;
}
private static void skipComment(ParsableByteArray parsableWebvttData) {
while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {}
}
}

View File

@ -18,6 +18,7 @@ package com.google.android.exoplayer.text.webvtt;
import com.google.android.exoplayer.ParserException;
import com.google.android.exoplayer.util.ParsableByteArray;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
@ -25,6 +26,7 @@ import java.util.regex.Pattern;
*/
public final class WebvttParserUtil {
private static final Pattern COMMENT = Pattern.compile("^NOTE((\u0020|\u0009).*)?$");
private static final Pattern HEADER = Pattern.compile("^\uFEFF?WEBVTT((\u0020|\u0009).*)?$");
private WebvttParserUtil() {}
@ -71,5 +73,29 @@ public final class WebvttParserUtil {
}
return Float.parseFloat(s.substring(0, s.length() - 1)) / 100;
}
/**
* Reads lines up to and including the next WebVTT cue header.
*
* @param input The input from which lines should be read.
* @return A {@link Matcher} for the WebVTT cue header, or null if the end of the input was
* reached without a cue header being found. In the case that a cue header is found, groups 1,
* 2 and 3 of the returned matcher contain the start time, end time and settings list.
*/
public static Matcher findNextCueHeader(ParsableByteArray input) {
String line;
while ((line = input.readLine()) != null) {
if (COMMENT.matcher(line).matches()) {
// Skip until the end of the comment block.
while ((line = input.readLine()) != null && !line.isEmpty()) {}
} else {
Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(line);
if (cueHeaderMatcher.matches()) {
return cueHeaderMatcher;
}
}
}
return null;
}
}