diff --git a/library/src/androidTest/assets/webvtt/with_css_complex_selectors b/library/src/androidTest/assets/webvtt/with_css_complex_selectors new file mode 100644 index 0000000000..62e3348ae9 --- /dev/null +++ b/library/src/androidTest/assets/webvtt/with_css_complex_selectors @@ -0,0 +1,40 @@ +WEBVTT + +STYLE +::cue(\n#id ){text-decoration:underline;} + +STYLE +::cue(#id.class1.class2 ){ color: violet;} + +STYLE +::cue(lang){font-family:Courier} + +STYLE +::cue(.class.another ){font-weight: bold;} + +STYLE +::cue(v.class[voice="Strider Trancos"] ){ font-weight:bold; } + +STYLE +::cue(v#anId.class1.class2[voice="Robert"] ){ font-style:italic; } + +id +00:00.000 --> 00:01.001 +This should be underlined and courier and violet. + +íd +00:02.000 --> 00:02.001 +This should be just courier. + +_id +00:02.500 --> 00:02.501 +This should be courier and bold. + +00:04.000 --> 00:04.001 +This shouldn't be bold. +This should be bold. + +anId +00:05.000 --> 00:05.001 +This is specific + But this is more italic diff --git a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/CssParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/CssParserTest.java index 4f3959f23d..5bf39211eb 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/CssParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/CssParserTest.java @@ -19,9 +19,6 @@ import com.google.android.exoplayer.util.ParsableByteArray; import android.test.InstrumentationTestCase; -import java.util.HashMap; -import java.util.Map; - /** * Unit test for {@link CssParser}. */ @@ -82,75 +79,46 @@ public final class CssParserTest extends InstrumentationTestCase { } public void testParseMethodSimpleInput() { - String styleBlock = " ::cue { color : black; background-color: PapayaWhip }"; - // Expected style map construction. - Map expectedResult = new HashMap<>(); - expectedResult.put("", new WebvttCssStyle()); - WebvttCssStyle style = expectedResult.get(""); - style.setFontColor(0xFF000000); - style.setBackgroundColor(0xFFFFEFD5); + String styleBlock1 = " ::cue { color : black; background-color: PapayaWhip }"; + WebvttCssStyle expectedStyle = new WebvttCssStyle(); + expectedStyle.setFontColor(0xFF000000); + expectedStyle.setBackgroundColor(0xFFFFEFD5); + assertParserProduces(expectedStyle, styleBlock1); - assertCssProducesExpectedMap(expectedResult, new String[] { styleBlock }); - } + String styleBlock2 = " ::cue { color : black }\n\n::cue { color : invalid }"; + expectedStyle = new WebvttCssStyle(); + expectedStyle.setFontColor(0xFF000000); + assertParserProduces(expectedStyle, styleBlock2); - public void testParseSimpleInputSeparately() { - String styleBlock1 = " ::cue { color : black }\n\n::cue { color : invalid }"; - String styleBlock2 = " \n::cue {\n background-color\n:#00fFFe}"; - - // Expected style map construction. - Map expectedResult = new HashMap<>(); - expectedResult.put("", new WebvttCssStyle()); - WebvttCssStyle style = expectedResult.get(""); - style.setFontColor(0xFF000000); - style.setBackgroundColor(0xFF00FFFE); - - assertCssProducesExpectedMap(expectedResult, new String[] { styleBlock1, styleBlock2 }); - } - - public void testDifferentSelectors() { - String styleBlock1 = " ::cue(\n#id ){text-decoration:underline;}"; - String styleBlock2 = "::cue(elem ){font-family:Courier}"; - String styleBlock3 = "::cue(.class ){font-weight: bold;}"; - - // Expected style map construction. - Map expectedResult = new HashMap<>(); - expectedResult.put("#id", new WebvttCssStyle().setUnderline(true)); - expectedResult.put("elem", new WebvttCssStyle().setFontFamily("courier")); - expectedResult.put(".class", new WebvttCssStyle().setBold(true)); - - assertCssProducesExpectedMap(expectedResult, new String[] { styleBlock1, styleBlock2, - styleBlock3}); + String styleBlock3 = " \n::cue {\n background-color\n:#00fFFe}"; + expectedStyle = new WebvttCssStyle(); + expectedStyle.setBackgroundColor(0xFF00FFFE); + assertParserProduces(expectedStyle, styleBlock3); } public void testMultiplePropertiesInBlock() { String styleBlock = "::cue(#id){text-decoration:underline; background-color:green;" + "color:red; font-family:Courier; font-weight:bold}"; - - // Expected style map construction. - Map expectedResult = new HashMap<>(); WebvttCssStyle expectedStyle = new WebvttCssStyle(); - expectedResult.put("#id", expectedStyle); + expectedStyle.setTargetId("id"); expectedStyle.setUnderline(true); expectedStyle.setBackgroundColor(0xFF008000); expectedStyle.setFontColor(0xFFFF0000); expectedStyle.setFontFamily("courier"); expectedStyle.setBold(true); - assertCssProducesExpectedMap(expectedResult, new String[] { styleBlock }); + assertParserProduces(expectedStyle, styleBlock); } public void testRgbaColorExpression() { String styleBlock = "::cue(#rgb){background-color: rgba(\n10/* Ugly color */,11\t, 12\n,.1);" + "color:rgb(1,1,\n1)}"; - - // Expected style map construction. - Map expectedResult = new HashMap<>(); WebvttCssStyle expectedStyle = new WebvttCssStyle(); - expectedResult.put("#rgb", expectedStyle); + expectedStyle.setTargetId("rgb"); expectedStyle.setBackgroundColor(0x190A0B0C); expectedStyle.setFontColor(0xFF010101); - assertCssProducesExpectedMap(expectedResult, new String[] { styleBlock }); + assertParserProduces(expectedStyle, styleBlock); } public void testGetNextToken() { @@ -181,20 +149,20 @@ public final class CssParserTest extends InstrumentationTestCase { public void testStyleScoreSystem() { WebvttCssStyle style = new WebvttCssStyle(); // Universal selector. - assertEquals(1, style.getSpecificityScore(null, null, new String[0], null)); + assertEquals(1, style.getSpecificityScore("", "", new String[0], "")); // Class match without tag match. style.setTargetClasses(new String[] { "class1", "class2"}); - assertEquals(8, style.getSpecificityScore(null, null, - new String[] { "class1", "class2", "class3" }, null)); + assertEquals(8, style.getSpecificityScore("", "", new String[] { "class1", "class2", "class3" }, + "")); // Class and tag match style.setTargetTagName("b"); - assertEquals(10, style.getSpecificityScore(null, "b", - new String[] { "class1", "class2", "class3" }, null)); + assertEquals(10, style.getSpecificityScore("", "b", + new String[] { "class1", "class2", "class3" }, "")); // Class insufficiency. - assertEquals(0, style.getSpecificityScore(null, "b", new String[] { "class1", "class" }, null)); + assertEquals(0, style.getSpecificityScore("", "b", new String[] { "class1", "class" }, "")); // Voice, classes and tag match. style.setTargetVoice("Manuel Cráneo"); - assertEquals(14, style.getSpecificityScore(null, "b", + assertEquals(14, style.getSpecificityScore("", "b", new String[] { "class1", "class2", "class3" }, "Manuel Cráneo")); // Voice mismatch. assertEquals(0, style.getSpecificityScore(null, "b", @@ -205,7 +173,7 @@ public final class CssParserTest extends InstrumentationTestCase { new String[] { "class1", "class2", "class3" }, "Manuel Cráneo")); // Id mismatch. assertEquals(0, style.getSpecificityScore("id1", "b", - new String[] { "class1", "class2", "class3" }, null)); + new String[] { "class1", "class2", "class3" }, "")); } // Utility methods. @@ -222,38 +190,25 @@ public final class CssParserTest extends InstrumentationTestCase { assertEquals(expectedLine, input.readLine()); } - private void assertCssProducesExpectedMap(Map expectedResult, - String[] styleBlocks){ - Map actualStyleMap = new HashMap<>(); - for (String s : styleBlocks) { - ParsableByteArray input = new ParsableByteArray(s.getBytes()); - parser.parseBlock(input, actualStyleMap); + private void assertParserProduces(WebvttCssStyle expected, + String styleBlock){ + ParsableByteArray input = new ParsableByteArray(styleBlock.getBytes()); + WebvttCssStyle actualElem = parser.parseBlock(input); + assertEquals(expected.hasBackgroundColor(), actualElem.hasBackgroundColor()); + if (expected.hasBackgroundColor()) { + assertEquals(expected.getBackgroundColor(), actualElem.getBackgroundColor()); } - assertStyleMapsAreEqual(expectedResult, actualStyleMap); - } - - private void assertStyleMapsAreEqual(Map expected, - Map actual) { - assertEquals(expected.size(), actual.size()); - for (String k : expected.keySet()) { - WebvttCssStyle expectedElem = expected.get(k); - WebvttCssStyle actualElem = actual.get(k); - assertEquals(expectedElem.hasBackgroundColor(), actualElem.hasBackgroundColor()); - if (expectedElem.hasBackgroundColor()) { - assertEquals(expectedElem.getBackgroundColor(), actualElem.getBackgroundColor()); - } - assertEquals(expectedElem.hasFontColor(), actualElem.hasFontColor()); - if (expectedElem.hasFontColor()) { - assertEquals(expectedElem.getFontColor(), actualElem.getFontColor()); - } - assertEquals(expectedElem.getFontFamily(), actualElem.getFontFamily()); - assertEquals(expectedElem.getFontSize(), actualElem.getFontSize()); - assertEquals(expectedElem.getFontSizeUnit(), actualElem.getFontSizeUnit()); - assertEquals(expectedElem.getStyle(), actualElem.getStyle()); - assertEquals(expectedElem.isLinethrough(), actualElem.isLinethrough()); - assertEquals(expectedElem.isUnderline(), actualElem.isUnderline()); - assertEquals(expectedElem.getTextAlign(), actualElem.getTextAlign()); + assertEquals(expected.hasFontColor(), actualElem.hasFontColor()); + if (expected.hasFontColor()) { + assertEquals(expected.getFontColor(), actualElem.getFontColor()); } + assertEquals(expected.getFontFamily(), actualElem.getFontFamily()); + assertEquals(expected.getFontSize(), actualElem.getFontSize()); + assertEquals(expected.getFontSizeUnit(), actualElem.getFontSizeUnit()); + assertEquals(expected.getStyle(), actualElem.getStyle()); + assertEquals(expected.isLinethrough(), actualElem.isLinethrough()); + assertEquals(expected.isUnderline(), actualElem.isUnderline()); + assertEquals(expected.getTextAlign(), actualElem.getTextAlign()); } } diff --git a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttCueParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttCueParserTest.java index 5883daae3a..1f7f6b9015 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttCueParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttCueParserTest.java @@ -223,8 +223,7 @@ public final class WebvttCueParserTest extends InstrumentationTestCase { private static Spanned parseCueText(String string) { WebvttCue.Builder builder = new WebvttCue.Builder(); - WebvttCueParser.parseCueText(null, string, builder, - Collections.emptyMap()); + WebvttCueParser.parseCueText(null, string, builder, Collections.emptyList()); return (Spanned) builder.build().text; } diff --git a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java index 445988918f..316aa9d104 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java @@ -26,6 +26,7 @@ import android.text.Spanned; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; import java.io.IOException; @@ -43,6 +44,7 @@ public class WebvttParserTest extends InstrumentationTestCase { 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 WITH_CSS_COMPLEX_SELECTORS = "webvtt/with_css_complex_selectors"; private static final String EMPTY_FILE = "webvtt/empty"; public void testParseEmpty() throws IOException { @@ -57,9 +59,7 @@ public class WebvttParserTest extends InstrumentationTestCase { } public void testParseTypical() throws IOException { - WebvttParser parser = new WebvttParser(); - byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_FILE); - WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); + WebvttSubtitle subtitle = getSubtitleForTestAsset(TYPICAL_FILE); // Test event count. assertEquals(4, subtitle.getEventTimeCount()); @@ -70,9 +70,7 @@ public class WebvttParserTest extends InstrumentationTestCase { } public void testParseTypicalWithIds() throws IOException { - WebvttParser parser = new WebvttParser(); - byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_WITH_IDS_FILE); - WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); + WebvttSubtitle subtitle = getSubtitleForTestAsset(TYPICAL_WITH_IDS_FILE); // Test event count. assertEquals(4, subtitle.getEventTimeCount()); @@ -83,9 +81,7 @@ public class WebvttParserTest extends InstrumentationTestCase { } public void testParseTypicalWithComments() throws IOException { - WebvttParser parser = new WebvttParser(); - byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_WITH_COMMENTS_FILE); - WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); + WebvttSubtitle subtitle = getSubtitleForTestAsset(TYPICAL_WITH_COMMENTS_FILE); // test event count assertEquals(4, subtitle.getEventTimeCount()); @@ -96,9 +92,7 @@ public class WebvttParserTest extends InstrumentationTestCase { } public void testParseWithTags() throws IOException { - WebvttParser parser = new WebvttParser(); - byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_TAGS_FILE); - WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_TAGS_FILE); // Test event count. assertEquals(8, subtitle.getEventTimeCount()); @@ -111,13 +105,9 @@ public class WebvttParserTest extends InstrumentationTestCase { } public void testParseWithPositioning() throws IOException { - WebvttParser parser = new WebvttParser(); - byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_POSITIONING_FILE); - WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); - + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_POSITIONING_FILE); // Test event count. assertEquals(12, subtitle.getEventTimeCount()); - // 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); @@ -133,15 +123,13 @@ public class WebvttParserTest extends InstrumentationTestCase { 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); - assertCue(subtitle, 10, 10000000, 11000000, "This is the sixth subtitle.", + assertCue(subtitle, 10, 10000000, 11000000, "This is the sixth subtitle.", Alignment.ALIGN_CENTER, 0.45f, Cue.LINE_TYPE_FRACTION, Cue.ANCHOR_TYPE_END, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, 0.35f); } public void testParseWithBadCueHeader() throws IOException { - WebvttParser parser = new WebvttParser(); - byte[] bytes = TestUtil.getByteArray(getInstrumentation(), WITH_BAD_CUE_HEADER_FILE); - WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_BAD_CUE_HEADER_FILE); // Test event count. assertEquals(4, subtitle.getEventTimeCount()); @@ -150,11 +138,9 @@ public class WebvttParserTest extends InstrumentationTestCase { 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); + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_CSS_STYLES); // Test event count. assertEquals(8, subtitle.getEventTimeCount()); @@ -162,15 +148,11 @@ public class WebvttParserTest extends InstrumentationTestCase { // 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); - Cue cue3 = subtitle.getCues(20000000).get(0); - Cue cue4 = subtitle.getCues(25000000).get(0); - Spanned s1 = (Spanned) cue1.text; - Spanned s2 = (Spanned) cue2.text; - Spanned s3 = (Spanned) cue3.text; - Spanned s4 = (Spanned) cue4.text; + + Spanned s1 = getUniqueSpanTextAt(subtitle, 0); + Spanned s2 = getUniqueSpanTextAt(subtitle, 2345000); + Spanned s3 = getUniqueSpanTextAt(subtitle, 20000000); + Spanned s4 = getUniqueSpanTextAt(subtitle, 25000000); 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); @@ -180,6 +162,46 @@ public class WebvttParserTest extends InstrumentationTestCase { assertEquals(Typeface.BOLD, s4.getSpans(17, s4.length(), StyleSpan.class)[0].getStyle()); } + public void testWithComplexCssSelectors() throws IOException { + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_CSS_COMPLEX_SELECTORS); + Spanned text = getUniqueSpanTextAt(subtitle, 0); + assertEquals(1, text.getSpans(30, text.length(), ForegroundColorSpan.class).length); + assertEquals(0xFFEE82EE, + text.getSpans(30, text.length(), ForegroundColorSpan.class)[0].getForegroundColor()); + assertEquals(1, text.getSpans(30, text.length(), TypefaceSpan.class).length); + assertEquals("courier", text.getSpans(30, text.length(), TypefaceSpan.class)[0].getFamily()); + + text = getUniqueSpanTextAt(subtitle, 2000000); + assertEquals(1, text.getSpans(5, text.length(), TypefaceSpan.class).length); + assertEquals("courier", text.getSpans(5, text.length(), TypefaceSpan.class)[0].getFamily()); + + text = getUniqueSpanTextAt(subtitle, 2500000); + assertEquals(1, text.getSpans(5, text.length(), StyleSpan.class).length); + assertEquals(Typeface.BOLD, text.getSpans(5, text.length(), StyleSpan.class)[0].getStyle()); + assertEquals(1, text.getSpans(5, text.length(), TypefaceSpan.class).length); + assertEquals("courier", text.getSpans(5, text.length(), TypefaceSpan.class)[0].getFamily()); + + text = getUniqueSpanTextAt(subtitle, 4000000); + assertEquals(0, text.getSpans(6, 22, StyleSpan.class).length); + assertEquals(1, text.getSpans(30, text.length(), StyleSpan.class).length); + assertEquals(Typeface.BOLD, text.getSpans(30, text.length(), StyleSpan.class)[0].getStyle()); + + text = getUniqueSpanTextAt(subtitle, 5000000); + assertEquals(0, text.getSpans(9, 17, StyleSpan.class).length); + assertEquals(1, text.getSpans(19, text.length(), StyleSpan.class).length); + assertEquals(Typeface.ITALIC, text.getSpans(19, text.length(), StyleSpan.class)[0].getStyle()); + } + + private WebvttSubtitle getSubtitleForTestAsset(String asset) throws IOException { + WebvttParser parser = new WebvttParser(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), asset); + return parser.decode(bytes, bytes.length); + } + + private Spanned getUniqueSpanTextAt(WebvttSubtitle sub, long timeUs) { + return (Spanned) sub.getCues(timeUs).get(0).text; + } + private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs, int endTimeUs, String text) { assertCue(subtitle, eventTimeIndex, startTimeUs, endTimeUs, text, null, Cue.DIMEN_UNSET, diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/CssParser.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/CssParser.java index ff72e2f52b..3792d039f9 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/CssParser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/CssParser.java @@ -20,7 +20,9 @@ import com.google.android.exoplayer.util.ParsableByteArray; import android.text.TextUtils; -import java.util.Map; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Provides a CSS parser for STYLE blocks in Webvtt files. Supports only a subset of the CSS @@ -32,9 +34,14 @@ import java.util.Map; private static final String PROPERTY_FONT_FAMILY = "font-family"; private static final String PROPERTY_FONT_WEIGHT = "font-weight"; private static final String PROPERTY_TEXT_DECORATION = "text-decoration"; - private static final String VALUE_BOLD = "bold"; private static final String VALUE_UNDERLINE = "underline"; + private static final String BLOCK_START = "{"; + private static final String BLOCK_END = "}"; + private static final String PROPERTY_FONT_STYLE = "font-style"; + private static final String VALUE_ITALIC = "italic"; + + private static final Pattern VOICE_NAME_PATTERN = Pattern.compile("\\[voice=\"([^\"]*)\"\\]"); // Temporary utility data structures. private final ParsableByteArray styleInput; @@ -51,57 +58,41 @@ import java.util.Map; * {@code null} otherwise. * * @param input The input from which the style block should be read. - * @param styleMap The map that contains styles accessible by selector. + * @return A {@link WebvttCssStyle} that represents the parsed block. */ - public void parseBlock(ParsableByteArray input, Map styleMap) { + public WebvttCssStyle parseBlock(ParsableByteArray input) { stringBuilder.setLength(0); int initialInputPosition = input.getPosition(); skipStyleBlock(input); styleInput.reset(input.data, input.getPosition()); styleInput.setPosition(initialInputPosition); String selector = parseSelector(styleInput, stringBuilder); - if (selector == null) { - return; + if (selector == null || !BLOCK_START.equals(parseNextToken(styleInput, stringBuilder))) { + return null; } - String token = parseNextToken(styleInput, stringBuilder); - if (!"{".equals(token)) { - return; - } - if (!styleMap.containsKey(selector)) { - styleMap.put(selector, new WebvttCssStyle()); - } - WebvttCssStyle style = styleMap.get(selector); + WebvttCssStyle style = new WebvttCssStyle(); + applySelectorToStyle(style, selector); + String token = null; boolean blockEndFound = false; while (!blockEndFound) { int position = styleInput.getPosition(); token = parseNextToken(styleInput, stringBuilder); - if (token == null || "}".equals(token)) { - blockEndFound = true; - } else { + blockEndFound = token == null || BLOCK_END.equals(token); + if (!blockEndFound) { styleInput.setPosition(position); parseStyleDeclaration(styleInput, style, stringBuilder); } } - // Only one style block may appear after a STYLE line. + return BLOCK_END.equals(token) ? style : null; // Check that the style block ended correctly. } /** - * Returns a string containing the selector. {@link WebvttCueParser#UNIVERSAL_CUE_ID} is the - * universal selector, and null means syntax error. - * - *

Expected inputs are: - *

    - *
  • ::cue - *
  • ::cue(#id) - *
  • ::cue(elem) - *
  • ::cue(.class) - *
  • ::cue(elem.class) - *
  • ::cue(v[voice="Someone"]) - *
+ * Returns a string containing the selector. The input is expected to have the form + * {@code ::cue(tag#id.class1.class2[voice="someone"]}, where every element is optional. * * @param input From which the selector is obtained. - * @return A string containing the target, {@link WebvttCueParser#UNIVERSAL_CUE_ID} if the - * selector is universal (targets all cues) or null if an error was encountered. + * @return A string containing the target, empty string if the selector is universal + * (targets all cues) or null if an error was encountered. */ private static String parseSelector(ParsableByteArray input, StringBuilder stringBuilder) { skipWhitespaceAndComments(input); @@ -117,9 +108,9 @@ import java.util.Map; if (token == null) { return null; } - if ("{".equals(token)) { + if (BLOCK_START.equals(token)) { input.setPosition(position); - return WebvttCueParser.UNIVERSAL_CUE_ID; + return ""; } String target = null; if ("(".equals(token)) { @@ -166,7 +157,7 @@ import java.util.Map; String token = parseNextToken(input, stringBuilder); if (";".equals(token)) { // The style declaration is well formed. - } else if ("}".equals(token)) { + } else if (BLOCK_END.equals(token)) { // The style declaration is well formed and we can go on, but the closing bracket had to be // fed back. input.setPosition(position); @@ -189,6 +180,10 @@ import java.util.Map; if (VALUE_BOLD.equals(value)) { style.setBold(true); } + } else if (PROPERTY_FONT_STYLE.equals(property)) { + if (VALUE_ITALIC.equals(value)) { + style.setItalic(true); + } } // TODO: Fill remaining supported styles. } @@ -256,7 +251,7 @@ import java.util.Map; // Syntax error. return null; } - if ("}".equals(token) || ";".equals(token)) { + if (BLOCK_END.equals(token) || ";".equals(token)) { input.setPosition(position); expressionEndFound = true; } else { @@ -305,5 +300,34 @@ import java.util.Map; return stringBuilder.toString(); } -} + /** + * Sets the target of a {@link WebvttCssStyle} by splitting a selector of the form + * {@code ::cue(tag#id.class1.class2[voice="someone"]}, where every element is optional. + */ + private void applySelectorToStyle(WebvttCssStyle style, String selector) { + if ("".equals(selector)) { + return; // Universal selector. + } + int voiceStartPosition = selector.indexOf('['); + if (voiceStartPosition != -1) { + Matcher matcher = VOICE_NAME_PATTERN.matcher(selector.substring(voiceStartPosition)); + if (matcher.matches()) { + style.setTargetVoice(matcher.group(1)); + } + selector = selector.substring(0, voiceStartPosition); + } + String[] classDivision = selector.split("\\."); + String tagAndIdDivision = classDivision[0]; + int idPrefixPosition = tagAndIdDivision.indexOf('#'); + if (idPrefixPosition != -1) { + style.setTargetTagName(tagAndIdDivision.substring(0, idPrefixPosition)); + style.setTargetId(tagAndIdDivision.substring(idPrefixPosition + 1)); // We discard the '#'. + } else { + style.setTargetTagName(tagAndIdDivision); + } + if (classDivision.length > 1) { + style.setTargetClasses(Arrays.copyOfRange(classDivision, 1, classDivision.length)); + } + } +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/Mp4WebvttParser.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/Mp4WebvttParser.java index 4b86a97ab7..a6b3f6a405 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/Mp4WebvttParser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/Mp4WebvttParser.java @@ -83,8 +83,8 @@ public final class Mp4WebvttParser extends SubtitleParser { if (boxType == TYPE_sttg) { WebvttCueParser.parseCueSettingsList(boxPayload, builder); } else if (boxType == TYPE_payl) { - WebvttCueParser.parseCueText(null, boxPayload.trim(), builder, - Collections.emptyMap()); + WebvttCueParser.parseCueText(null, boxPayload.trim(), builder, + Collections.emptyList()); } else { // Other VTTCueBox children are still not supported and are ignored. } diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCssStyle.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCssStyle.java index 508dc8d16d..9e9ddb5f28 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCssStyle.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCssStyle.java @@ -124,8 +124,9 @@ import java.util.List; public int getSpecificityScore(String id, String tag, String[] classes, String voice) { if (targetId.isEmpty() && targetTag.isEmpty() && targetClasses.isEmpty() && targetVoice.isEmpty()) { - // The selector is universal. It matches with the minimum score. - return 1; + // The selector is universal. It matches with the minimum score if and only if the given + // element is a whole cue. + return tag.isEmpty() ? 1 : 0; } int score = 0; score = updateScoreForMatch(score, targetId, id, 0x40000000); diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCueParser.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCueParser.java index 7cf0e392f0..448bfb48be 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCueParser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCueParser.java @@ -34,7 +34,10 @@ import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; import android.util.Log; -import java.util.Map; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Stack; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -44,7 +47,6 @@ import java.util.regex.Pattern; */ /* package */ final class WebvttCueParser { - public static final String UNIVERSAL_CUE_ID = ""; public static final Pattern CUE_HEADER_PATTERN = Pattern .compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$"); @@ -68,10 +70,6 @@ import java.util.regex.Pattern; private static final String TAG_CLASS = "c"; private static final String TAG_VOICE = "v"; private static final String TAG_LANG = "lang"; - - private static final String CUE_ID_PREFIX = "#"; - private static final String CUE_VOICE_PREFIX = "v[voice=\""; - private static final String CUE_VOICE_SUFFIX = "\"]"; private static final int STYLE_BOLD = Typeface.BOLD; private static final int STYLE_ITALIC = Typeface.ITALIC; @@ -83,30 +81,30 @@ import java.util.regex.Pattern; public WebvttCueParser() { textBuilder = new StringBuilder(); } - + /** * Parses the next valid WebVTT cue in a parsable array, including timestamps, settings and text. * * @param webvttData Parsable WebVTT file data. * @param builder Builder for WebVTT Cues. - * @param styleMap Maps selector to style as referenced by the CSS ::cue pseudo-element. + * @param styles List of styles defined by the CSS style blocks preceeding the cues. * @return True if a valid Cue was found, false otherwise. */ /* package */ boolean parseCue(ParsableByteArray webvttData, WebvttCue.Builder builder, - Map styleMap) { + List styles) { 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(null, cueHeaderMatcher, webvttData, builder, textBuilder, styleMap); + return parseCue(null, cueHeaderMatcher, webvttData, builder, textBuilder, styles); } 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. - return parseCue(firstLine.trim(), cueHeaderMatcher, webvttData, builder, textBuilder, - styleMap); + return parseCue(firstLine.trim(), cueHeaderMatcher, webvttData, builder, textBuilder, + styles); } } return false; @@ -148,13 +146,14 @@ import java.util.regex.Pattern; * * @param id Id of the cue, {@code null} if it is not present. * @param markup The markup text to be parsed. - * @param styleMap Maps selector to style as referenced by the CSS ::cue pseudo-element. + * @param styles List of styles defined by the CSS style blocks preceeding the cues. * @param builder Target builder. */ /* package */ static void parseCueText(String id, String markup, WebvttCue.Builder builder, - Map styleMap) { + List styles) { SpannableStringBuilder spannedText = new SpannableStringBuilder(); Stack startTagStack = new Stack<>(); + List scratchStyleMatches = new ArrayList<>(); int pos = 0; while (pos < markup.length()) { char curr = markup.charAt(pos); @@ -181,11 +180,10 @@ import java.util.regex.Pattern; break; } startTag = startTagStack.pop(); - applySpansForTag(startTag, spannedText, styleMap); + applySpansForTag(id, startTag, spannedText, styles, scratchStyleMatches); } while(!startTag.name.equals(tagName)); } else if (!isVoidTag) { - startTagStack.push(new StartTag(tagName, spannedText.length(), - TAG_VOICE.equals(tagName) ? getVoiceName(fullTagExpression) : null)); + startTagStack.push(StartTag.buildStartTag(fullTagExpression, spannedText.length())); } break; case CHAR_AMPERSAND: @@ -211,16 +209,16 @@ import java.util.regex.Pattern; } } // apply unclosed tags - applyStyleToText(spannedText, styleMap.get(UNIVERSAL_CUE_ID), 0, spannedText.length()); while (!startTagStack.isEmpty()) { - applySpansForTag(startTagStack.pop(), spannedText, styleMap); + applySpansForTag(id, startTagStack.pop(), spannedText, styles, scratchStyleMatches); } - applyStyleToText(spannedText, styleMap.get(CUE_ID_PREFIX + id), 0, spannedText.length()); + applySpansForTag(id, StartTag.buildWholeCueVirtualTag(), spannedText, styles, + scratchStyleMatches); builder.setText(spannedText); } private static boolean parseCue(String id, Matcher cueHeaderMatcher, ParsableByteArray webvttData, - WebvttCue.Builder builder, StringBuilder textBuilder, Map styleMap) { + WebvttCue.Builder builder, StringBuilder textBuilder, List styles) { try { // Parse the cue start and end times. builder.setStartTime(WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1))) @@ -241,7 +239,7 @@ import java.util.regex.Pattern; } textBuilder.append(line.trim()); } - parseCueText(id, textBuilder.toString(), builder, styleMap); + parseCueText(id, textBuilder.toString(), builder, styles); return true; } @@ -353,40 +351,40 @@ import java.util.regex.Pattern; } } - private static void applySpansForTag(StartTag startTag, SpannableStringBuilder spannedText, - Map styleMap) { - WebvttCssStyle styleForTag = styleMap.get(startTag.name); + private static void applySpansForTag(String cueId, StartTag startTag, SpannableStringBuilder text, + List styles, List scratchStyleMatches) { int start = startTag.position; - int end = spannedText.length(); + int end = text.length(); switch(startTag.name) { case TAG_BOLD: - spannedText.setSpan(new StyleSpan(STYLE_BOLD), start, end, + text.setSpan(new StyleSpan(STYLE_BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_ITALIC: - spannedText.setSpan(new StyleSpan(STYLE_ITALIC), start, end, + text.setSpan(new StyleSpan(STYLE_ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_UNDERLINE: - spannedText.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_CLASS: case TAG_LANG: case TAG_VOICE: - break; + case "": // Case of the "whole cue" virtual tag. + break; default: return; } - applyStyleToText(spannedText, styleForTag, start, end); - if (startTag.voiceName != null) { - WebvttCssStyle styleForVoice = styleMap.get(CUE_VOICE_PREFIX + startTag.voiceName - + CUE_VOICE_SUFFIX); - applyStyleToText(spannedText, styleForVoice, start, end); + scratchStyleMatches.clear(); + getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); + int styleMatchesCount = scratchStyleMatches.size(); + for (int i = 0; i < styleMatchesCount; i++) { + applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); } } - - private static void applyStyleToText(SpannableStringBuilder spannedText, - WebvttCssStyle style, int start, int end) { + + private static void applyStyleToText(SpannableStringBuilder spannedText, WebvttCssStyle style, + int start, int end) { if (style == null) { return; } @@ -447,21 +445,79 @@ import java.util.regex.Pattern; } return tagExpression.split("[ \\.]")[0]; } - - private static String getVoiceName(String fullTagExpression) { - return fullTagExpression.trim().substring(fullTagExpression.indexOf(" ")).trim(); + + private static void getApplicableStyles(List declaredStyles, String id, + StartTag tag, List output) { + int styleCount = declaredStyles.size(); + for (int i = 0; i < styleCount; i++) { + WebvttCssStyle style = declaredStyles.get(i); + int score = style.getSpecificityScore(id, tag.name, tag.classes, tag.voice); + if (score > 0) { + output.add(new StyleMatch(score, style)); + } + } + Collections.sort(output); + } + + private static final class StyleMatch implements Comparable { + + public final int score; + public final WebvttCssStyle style; + + public StyleMatch(int score, WebvttCssStyle style) { + this.score = score; + this.style = style; + } + + @Override + public int compareTo(StyleMatch another) { + return this.score - another.score; + } + } private static final class StartTag { + private static final String[] NO_CLASSES = new String[0]; + public final String name; public final int position; - public final String voiceName; + public final String voice; + public final String[] classes; - public StartTag(String name, int position, String voiceName) { + private StartTag(String name, int position, String voice, String[] classes) { this.position = position; this.name = name; - this.voiceName = voiceName; + this.voice = voice; + this.classes = classes; + } + + public static StartTag buildStartTag(String fullTagExpression, int position) { + fullTagExpression = fullTagExpression.trim(); + if (fullTagExpression.isEmpty()) { + return null; + } + int voiceStart = fullTagExpression.indexOf(" "); + String voice; + if (voiceStart == -1) { + voice = ""; + } else { + voice = fullTagExpression.substring(voiceStart).trim(); + fullTagExpression = fullTagExpression.substring(0, voiceStart); + } + String[] nameAndClasses = fullTagExpression.split("\\."); + String name = nameAndClasses[0]; + String[] classes; + if (nameAndClasses.length > 1) { + classes = Arrays.copyOfRange(nameAndClasses, 1, nameAndClasses.length); + } else { + classes = NO_CLASSES; + } + return new StartTag(name, position, voice, classes); + } + + public static StartTag buildWholeCueVirtualTag() { + return new StartTag("", 0, "", new String[0]); } } diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java index 5586af0f90..a4bed9d25a 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java @@ -22,7 +22,7 @@ import com.google.android.exoplayer.util.ParsableByteArray; import android.text.TextUtils; import java.util.ArrayList; -import java.util.HashMap; +import java.util.List; /** * A simple WebVTT parser. @@ -38,32 +38,32 @@ public final class WebvttParser extends SubtitleParser { 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 styleMap; + private final List definedStyles; public WebvttParser() { cueParser = new WebvttCueParser(); parsableWebvttData = new ParsableByteArray(); webvttCueBuilder = new WebvttCue.Builder(); cssParser = new CssParser(); - styleMap = new HashMap<>(); + definedStyles = new ArrayList<>(); } @Override protected WebvttSubtitle decode(byte[] bytes, int length) throws ParserException { parsableWebvttData.reset(bytes, length); // Initialization for consistent starting state. - webvttCueBuilder.reset(); - styleMap.clear(); + webvttCueBuilder.reset(); + definedStyles.clear(); // Validate the first line of the header, and skip the remainder. WebvttParserUtil.validateWebvttHeaderLine(parsableWebvttData); while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {} - + int eventFound; ArrayList subtitles = new ArrayList<>(); while ((eventFound = getNextEvent(parsableWebvttData)) != END_OF_FILE_FOUND) { @@ -74,9 +74,12 @@ public final class WebvttParser extends SubtitleParser { throw new ParserException("A style block was found after the first cue."); } parsableWebvttData.readLine(); // Consume the "STYLE" header. - cssParser.parseBlock(parsableWebvttData, styleMap); + WebvttCssStyle styleBlock = cssParser.parseBlock(parsableWebvttData); + if (styleBlock != null) { + definedStyles.add(styleBlock); + } } else if (eventFound == CUE_FOUND) { - if (cueParser.parseCue(parsableWebvttData, webvttCueBuilder, styleMap)) { + if (cueParser.parseCue(parsableWebvttData, webvttCueBuilder, definedStyles)) { subtitles.add(webvttCueBuilder.build()); webvttCueBuilder.reset(); } @@ -84,11 +87,11 @@ public final class WebvttParser extends SubtitleParser { } 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) { @@ -110,7 +113,7 @@ public final class WebvttParser extends SubtitleParser { parsableWebvttData.setPosition(currentInputPosition); return foundEvent; } - + private static void skipComment(ParsableByteArray parsableWebvttData) { while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {} }