WebVTT styling.

- parse webvtt cue
- remove all tags from string (supported or not)
- apply spans for b, i and u
- honor class names in tags to properly parse the cue but do not apply styles for them
This commit is contained in:
Oliver Woodman 2015-11-27 16:02:12 +00:00
parent cdb6ac4073
commit 1a9b2be551
3 changed files with 469 additions and 3 deletions

View File

@ -0,0 +1,247 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer.text.webvtt;
import android.graphics.Typeface;
import android.test.InstrumentationTestCase;
import android.text.Spanned;
import android.text.style.StyleSpan;
import android.text.style.UnderlineSpan;
/**
* Unit test for {@link WebvttCueParser}.
*/
public final class WebvttCueParserTest extends InstrumentationTestCase {
public void testParseStrictValidClassesAndTrailingTokens() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("<v.first.loud Esme>"
+ "This <u.style1.style2 some stuff>is</u> text with <b.foo><i.bar>html</i></b> tags");
assertEquals("This is text with html tags", text.toString());
UnderlineSpan[] underlineSpans = getSpans(text, UnderlineSpan.class);
StyleSpan[] styleSpans = getSpans(text, StyleSpan.class);
assertEquals(1, underlineSpans.length);
assertEquals(2, styleSpans.length);
assertEquals(Typeface.ITALIC, styleSpans[0].getStyle());
assertEquals(Typeface.BOLD, styleSpans[1].getStyle());
assertEquals(5, text.getSpanStart(underlineSpans[0]));
assertEquals(7, text.getSpanEnd(underlineSpans[0]));
assertEquals(18, text.getSpanStart(styleSpans[0]));
assertEquals(18, text.getSpanStart(styleSpans[1]));
assertEquals(22, text.getSpanEnd(styleSpans[0]));
assertEquals(22, text.getSpanEnd(styleSpans[1]));
}
public void testParseStrictValidUnsupportedTagsStrippedOut() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse(
"<v.first.loud Esme>This <unsupported>is</unsupported> text with "
+ "<notsupp><invalid>html</invalid></notsupp> tags");
assertEquals("This is text with html tags", text.toString());
assertEquals(0, getSpans(text, UnderlineSpan.class).length);
assertEquals(0, getSpans(text, StyleSpan.class).length);
}
public void testParseWellFormedUnclosedEndAtCueEnd() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse(
"An <u some trailing stuff>unclosed u tag with <i>italic</i> inside");
assertEquals("An unclosed u tag with italic inside", text.toString());
UnderlineSpan[] underlineSpans = getSpans(text, UnderlineSpan.class);
StyleSpan[] styleSpans = getSpans(text, StyleSpan.class);
assertEquals(1, underlineSpans.length);
assertEquals(1, styleSpans.length);
assertEquals(Typeface.ITALIC, styleSpans[0].getStyle());
assertEquals(3, text.getSpanStart(underlineSpans[0]));
assertEquals(23, text.getSpanStart(styleSpans[0]));
assertEquals(29, text.getSpanEnd(styleSpans[0]));
assertEquals(36, text.getSpanEnd(underlineSpans[0]));
}
public void testParseWellFormedUnclosedEndAtParent() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse(
"An unclosed u tag with <i><u>underline and italic</i> inside");
assertEquals("An unclosed u tag with underline and italic inside", text.toString());
UnderlineSpan[] underlineSpans = getSpans(text, UnderlineSpan.class);
StyleSpan[] styleSpans = getSpans(text, StyleSpan.class);
assertEquals(1, underlineSpans.length);
assertEquals(1, styleSpans.length);
assertEquals(23, text.getSpanStart(underlineSpans[0]));
assertEquals(23, text.getSpanStart(styleSpans[0]));
assertEquals(43, text.getSpanEnd(underlineSpans[0]));
assertEquals(43, text.getSpanEnd(styleSpans[0]));
assertEquals(Typeface.ITALIC, styleSpans[0].getStyle());
}
public void testParseMalformedNestedElements() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse(
"<b><u>An unclosed u tag with <i>italic</u> inside</i></b>");
assertEquals("An unclosed u tag with italic inside", text.toString());
UnderlineSpan[] underlineSpans = getSpans(text, UnderlineSpan.class);
StyleSpan[] styleSpans = getSpans(text, StyleSpan.class);
assertEquals(1, underlineSpans.length);
assertEquals(2, styleSpans.length);
// all tags applied until matching start tag found
assertEquals(0, text.getSpanStart(underlineSpans[0]));
assertEquals(29, text.getSpanEnd(underlineSpans[0]));
if (styleSpans[0].getStyle() == Typeface.BOLD) {
assertEquals(0, text.getSpanStart(styleSpans[0]));
assertEquals(23, text.getSpanStart(styleSpans[1]));
assertEquals(29, text.getSpanEnd(styleSpans[1]));
assertEquals(36, text.getSpanEnd(styleSpans[0]));
} else {
assertEquals(0, text.getSpanStart(styleSpans[1]));
assertEquals(23, text.getSpanStart(styleSpans[0]));
assertEquals(29, text.getSpanEnd(styleSpans[0]));
assertEquals(36, text.getSpanEnd(styleSpans[1]));
}
}
public void testParseCloseNonExistingTag() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("blah<b>blah</i>blah</b>blah");
assertEquals("blahblahblahblah", text.toString());
StyleSpan[] spans = getSpans(text, StyleSpan.class);
assertEquals(1, spans.length);
assertEquals(Typeface.BOLD, spans[0].getStyle());
assertEquals(4, text.getSpanStart(spans[0]));
assertEquals(8, text.getSpanEnd(spans[0])); // should be 12 when valid
}
public void testParseEmptyTagName() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("An unclosed u tag with <>italic inside");
assertEquals("An unclosed u tag with italic inside", text.toString());
}
public void testParseEntities() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("&amp; &gt; &lt; &nbsp;");
assertEquals("& > < ", text.toString());
}
public void testParseEntitiesUnsupported() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("&noway; &sure;");
assertEquals(" ", text.toString());
}
public void testParseEntitiesNotTerminated() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("&amp here comes text");
assertEquals("& here comes text", text.toString());
}
public void testParseEntitiesNotTerminatedUnsupported() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("&surenot here comes text");
assertEquals(" here comes text", text.toString());
}
public void testParseEntitiesNotTerminatedNoSpace() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("&surenot");
assertEquals("&surenot", text.toString());
}
public void testParseVoidTag() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("here comes<br/> text<br/>");
assertEquals("here comes text", text.toString());
}
public void testParseMultipleTagsOfSameKind() {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("blah <b>blah</b> blah <b>foo</b>");
assertEquals("blah blah blah foo", text.toString());
StyleSpan[] spans = getSpans(text, StyleSpan.class);
assertEquals(2, spans.length);
assertEquals(5, text.getSpanStart(spans[0]));
assertEquals(9, text.getSpanEnd(spans[0]));
assertEquals(15, text.getSpanStart(spans[1]));
assertEquals(18, text.getSpanEnd(spans[1]));
assertEquals(Typeface.BOLD, spans[0].getStyle());
assertEquals(Typeface.BOLD, spans[1].getStyle());
}
public void testParseInvalidVoidSlash() {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse("blah <b/.st1.st2 trailing stuff> blah");
assertEquals("blah blah", text.toString());
StyleSpan[] spans = getSpans(text, StyleSpan.class);
assertEquals(0, spans.length);
}
public void testParseMonkey() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse(
"< u>An unclosed u tag with <<<<< i>italic</u></u></u></u ></i><u><u> inside");
assertEquals("An unclosed u tag with italic inside", text.toString());
text = parser.parse(">>>>>>>>>An unclosed u tag with <<<<< italic</u></u></u></u >"
+ "</i><u><u> inside");
assertEquals(">>>>>>>>>An unclosed u tag with inside", text.toString());
}
public void testParseCornerCases() throws Exception {
WebvttCueParser parser = new WebvttCueParser();
Spanned text = parser.parse(">");
assertEquals(">", text.toString());
text = parser.parse("<");
assertEquals("", text.toString());
text = parser.parse("<b.st1.st2 annotation");
assertEquals("", text.toString());
text = parser.parse("<<<<<<<<<<<<<<<<");
assertEquals("", text.toString());
text = parser.parse("<<<<<<>><<<<<<<<<<");
assertEquals(">", text.toString());
text = parser.parse("<>");
assertEquals("", text.toString());
text = parser.parse("&");
assertEquals("&", text.toString());
text = parser.parse("&&&&&&&");
assertEquals("&&&&&&&", text.toString());
}
private static <T> T[] getSpans(Spanned text, Class<T> spanType) {
return text.getSpans(0, text.length(), spanType);
}
}

View File

@ -0,0 +1,217 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer.text.webvtt;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.StyleSpan;
import android.text.style.UnderlineSpan;
import android.util.Log;
import java.util.Stack;
/**
* Parser for webvtt cue text. (https://w3c.github.io/webvtt/#cue-text)
*/
/* package */ final class WebvttCueParser {
private static final char CHAR_LESS_THAN = '<';
private static final char CHAR_GREATER_THAN = '>';
private static final char CHAR_SLASH = '/';
private static final char CHAR_AMPERSAND = '&';
private static final char CHAR_SEMI_COLON = ';';
private static final char CHAR_SPACE = ' ';
private static final String SPACE = " ";
private static final String ENTITY_LESS_THAN = "lt";
private static final String ENTITY_GREATER_THAN = "gt";
private static final String ENTITY_AMPERSAND = "amp";
private static final String ENTITY_NON_BREAK_SPACE = "nbsp";
private static final String TAG_BOLD = "b";
private static final String TAG_ITALIC = "i";
private static final String TAG_UNDERLINE = "u";
private static final String TAG_CLASS = "c";
private static final String TAG_VOICE = "v";
private static final String TAG_LANG = "lang";
private static final int STYLE_BOLD = Typeface.BOLD;
private static final int STYLE_ITALIC = Typeface.ITALIC;
private static final String TAG = "WebvttCueParser";
public Spanned parse(String markup) {
SpannableStringBuilder spannedText = new SpannableStringBuilder();
Stack<StartTag> startTagStack = new Stack<>();
String[] tagTokens;
int pos = 0;
while (pos < markup.length()) {
char curr = markup.charAt(pos);
switch (curr) {
case CHAR_LESS_THAN:
if (pos + 1 >= markup.length()) {
pos++;
break; // avoid ArrayOutOfBoundsException
}
int ltPos = pos;
boolean isClosingTag = markup.charAt(ltPos + 1) == CHAR_SLASH;
pos = findEndOfTag(markup, ltPos + 1);
boolean isVoidTag = markup.charAt(pos - 2) == CHAR_SLASH;
tagTokens = tokenizeTag(markup.substring(
ltPos + (isClosingTag ? 2 : 1), isVoidTag ? pos - 2 : pos - 1));
if (tagTokens == null || !isSupportedTag(tagTokens[0])) {
continue;
}
if (isClosingTag) {
StartTag startTag;
do {
if (startTagStack.isEmpty()) {
break;
}
startTag = startTagStack.pop();
applySpansForTag(startTag, spannedText);
} while(!startTag.name.equals(tagTokens[0]));
} else if (!isVoidTag) {
startTagStack.push(new StartTag(tagTokens[0], spannedText.length()));
}
break;
case CHAR_AMPERSAND:
int semiColonEnd = markup.indexOf(CHAR_SEMI_COLON, pos + 1);
int spaceEnd = markup.indexOf(CHAR_SPACE, pos + 1);
int entityEnd = semiColonEnd == -1 ? spaceEnd
: spaceEnd == -1 ? semiColonEnd : Math.min(semiColonEnd, spaceEnd);
if (entityEnd != -1) {
applyEntity(markup.substring(pos + 1, entityEnd), spannedText);
if (entityEnd == spaceEnd) {
spannedText.append(" ");
}
pos = entityEnd + 1;
} else {
spannedText.append(curr);
pos++;
}
break;
default:
spannedText.append(curr);
pos++;
break;
}
}
// apply unclosed tags
while (!startTagStack.isEmpty()) {
applySpansForTag(startTagStack.pop(), spannedText);
}
return spannedText;
}
/**
* Find end of tag (&gt;). The position returned is the position of the &gt; plus one (exclusive).
*
* @param markup The webvtt cue markup to be parsed.
* @param startPos the position from where to start searching for the end of tag.
* @return the position of the end of tag plus 1 (one).
*/
private int findEndOfTag(String markup, int startPos) {
int idx = markup.indexOf(CHAR_GREATER_THAN, startPos);
return idx == -1 ? markup.length() : idx + 1;
}
private void applyEntity(String entity, SpannableStringBuilder spannedText) {
switch (entity) {
case ENTITY_LESS_THAN:
spannedText.append('<');
break;
case ENTITY_GREATER_THAN:
spannedText.append('>');
break;
case ENTITY_NON_BREAK_SPACE:
spannedText.append(' ');
break;
case ENTITY_AMPERSAND:
spannedText.append('&');
break;
default:
Log.w(TAG, "ignoring unsupported entity: '&" + entity + ";'");
break;
}
}
private boolean isSupportedTag(String tagName) {
switch (tagName) {
case TAG_BOLD:
case TAG_CLASS:
case TAG_ITALIC:
case TAG_LANG:
case TAG_UNDERLINE:
case TAG_VOICE:
return true;
default:
return false;
}
}
private void applySpansForTag(StartTag startTag, SpannableStringBuilder spannedText) {
switch(startTag.name) {
case TAG_BOLD:
spannedText.setSpan(new StyleSpan(STYLE_BOLD), startTag.position,
spannedText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return;
case TAG_ITALIC:
spannedText.setSpan(new StyleSpan(STYLE_ITALIC), startTag.position,
spannedText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return;
case TAG_UNDERLINE:
spannedText.setSpan(new UnderlineSpan(), startTag.position,
spannedText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return;
default:
break;
}
}
/**
* Tokenizes a tag expression into tag name (pos 0) and classes (pos 1..n).
*
* @param fullTagExpression characters between &amp;lt: and &amp;gt; of a start or end tag
* @return an array of <code>String</code>s with the tag name at pos 0 followed by style classes
* or null if it's an empty tag: '&lt;&gt;'
*/
private String[] tokenizeTag(String fullTagExpression) {
fullTagExpression = fullTagExpression.replace("\\s+", " ").trim();
if (fullTagExpression.length() == 0) {
return null;
}
if (fullTagExpression.contains(SPACE)) {
fullTagExpression = fullTagExpression.substring(0, fullTagExpression.indexOf(SPACE));
}
return fullTagExpression.split("\\.");
}
private static final class StartTag {
public final String name;
public final int position;
public StartTag(String name, int position) {
this.position = position;
this.name = name;
}
}
}

View File

@ -21,7 +21,6 @@ import com.google.android.exoplayer.text.Cue;
import com.google.android.exoplayer.text.SubtitleParser;
import com.google.android.exoplayer.util.MimeTypes;
import android.text.Html;
import android.text.Layout.Alignment;
import android.text.TextUtils;
import android.util.Log;
@ -48,10 +47,12 @@ public final class WebvttParser implements SubtitleParser {
private static final Pattern CUE_HEADER = Pattern.compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$");
private static final Pattern CUE_SETTING = Pattern.compile("(\\S+?):(\\S+)");
private final WebvttCueParser cueParser;
private final PositionHolder positionHolder;
private final StringBuilder textBuilder;
public WebvttParser() {
this.cueParser = new WebvttCueParser();
positionHolder = new PositionHolder();
textBuilder = new StringBuilder();
}
@ -130,11 +131,12 @@ public final class WebvttParser implements SubtitleParser {
String line;
while ((line = webvttData.readLine()) != null && !line.isEmpty()) {
if (textBuilder.length() > 0) {
textBuilder.append("<br>");
textBuilder.append("\n");
}
textBuilder.append(line.trim());
}
CharSequence cueText = Html.fromHtml(textBuilder.toString());
CharSequence cueText = cueParser.parse(textBuilder.toString());
WebvttCue cue = new WebvttCue(cueStartTime, cueEndTime, cueText, cueTextAlignment, cueLine,
cueLineType, cueLineAnchor, cuePosition, cuePositionAnchor, cueWidth);