From c2beffc6c56b12cc8fe7b1e2aee06aa623b7afd9 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 13 Apr 2016 04:50:42 -0700 Subject: [PATCH] WebVTT CSS Styling -- Support for element selectors This CL allows style blocks to reference elements. For example: we could style a cue with text "Sometimes bold is not enough" with the style block ::cue(b) { ... }. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=119734779 --- .../androidTest/assets/webvtt/with_css_styles | 6 + .../text/webvtt/WebvttCueParserTest.java | 5 +- .../text/webvtt/WebvttParserTest.java | 6 +- .../exoplayer/text/webvtt/CssParser.java | 26 ++-- .../text/webvtt/Mp4WebvttParser.java | 4 +- .../exoplayer/text/webvtt/WebvttCue.java | 89 +------------ .../text/webvtt/WebvttCueParser.java | 121 ++++++++++++++---- .../exoplayer/text/webvtt/WebvttParser.java | 4 +- 8 files changed, 136 insertions(+), 125 deletions(-) diff --git a/library/src/androidTest/assets/webvtt/with_css_styles b/library/src/androidTest/assets/webvtt/with_css_styles index c7fcc4217c..12c8422b9b 100644 --- a/library/src/androidTest/assets/webvtt/with_css_styles +++ b/library/src/androidTest/assets/webvtt/with_css_styles @@ -14,6 +14,9 @@ STYLE color: peachpuff; } +STYLE +::cue(v){text-decoration:underline} + id1 00:00.000 --> 00:01.234 This is the first subtitle. @@ -21,3 +24,6 @@ This is the first subtitle. id2 00:02.345 --> 00:03.456 This is the second subtitle. + +00:20.000 --> 00:21.000 +This is a reference by element 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 0737476ad9..5883daae3a 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 @@ -21,6 +21,8 @@ import android.text.Spanned; import android.text.style.StyleSpan; import android.text.style.UnderlineSpan; +import java.util.Collections; + /** * Unit test for {@link WebvttCueParser}. */ @@ -221,7 +223,8 @@ public final class WebvttCueParserTest extends InstrumentationTestCase { private static Spanned parseCueText(String string) { WebvttCue.Builder builder = new WebvttCue.Builder(); - WebvttCueParser.parseCueText(string, builder); + WebvttCueParser.parseCueText(null, string, builder, + Collections.emptyMap()); 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 0d6152bdc5..1597062dc8 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 @@ -24,6 +24,7 @@ import android.text.Layout.Alignment; import android.text.Spanned; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; +import android.text.style.UnderlineSpan; import java.io.IOException; import java.util.List; @@ -154,7 +155,7 @@ public class WebvttParserTest extends InstrumentationTestCase { WebvttSubtitle subtitle = parser.decode(bytes, bytes.length); // Test event count. - assertEquals(4, subtitle.getEventTimeCount()); + assertEquals(6, subtitle.getEventTimeCount()); // Test cues. assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle."); @@ -162,11 +163,14 @@ public class WebvttParserTest extends InstrumentationTestCase { Cue cue1 = subtitle.getCues(0).get(0); Cue cue2 = subtitle.getCues(2345000).get(0); + Cue cue3 = subtitle.getCues(20000000).get(0); Spanned s1 = (Spanned) cue1.text; Spanned s2 = (Spanned) cue2.text; + Spanned s3 = (Spanned) cue3.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); + assertEquals(1, s3.getSpans(10, s3.length(), UnderlineSpan.class).length); } private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs, 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 8573d72dd8..818c720afe 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 @@ -86,20 +86,22 @@ import java.util.Map; } /** - * Returns a string containing the selector. Empty string is the universal selector, and null - * means syntax error. + * 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"]) + *

Expected inputs are: + *

* * @param input From which the selector is obtained. - * @return A string containing the target, {@link WebvttCue#UNIVERSAL_CUE_ID} if targets all cues - * and null if an error was encountered. + * @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. */ private static String parseSelector(ParsableByteArray input, StringBuilder stringBuilder) { skipWhitespaceAndComments(input); @@ -117,7 +119,7 @@ import java.util.Map; } if ("{".equals(token)) { input.setPosition(position); - return WebvttCue.UNIVERSAL_CUE_ID; + return WebvttCueParser.UNIVERSAL_CUE_ID; } String target = null; if ("(".equals(token)) { 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 a8e49070ee..4b86a97ab7 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 @@ -22,6 +22,7 @@ import com.google.android.exoplayer.util.ParsableByteArray; import com.google.android.exoplayer.util.Util; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -82,7 +83,8 @@ public final class Mp4WebvttParser extends SubtitleParser { if (boxType == TYPE_sttg) { WebvttCueParser.parseCueSettingsList(boxPayload, builder); } else if (boxType == TYPE_payl) { - WebvttCueParser.parseCueText(boxPayload.trim(), builder); + WebvttCueParser.parseCueText(null, boxPayload.trim(), builder, + Collections.emptyMap()); } else { // Other VTTCueBox children are still not supported and are ignored. } diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java index bb0d87b7ec..7ca8c0002d 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java @@ -18,32 +18,14 @@ 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 static final String CUE_ID_PREFIX = "#"; - - public final String id; public final long startTime; public final long endTime; @@ -52,15 +34,13 @@ import java.util.Map; } public WebvttCue(long startTime, long endTime, CharSequence text) { - this(null, startTime, endTime, text, null, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, + 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(String id, long startTime, long endTime, CharSequence text, - Alignment textAlignment, float line, int lineType, int lineAnchor, float position, - int positionAnchor, float width) { + public WebvttCue(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; } @@ -83,7 +63,6 @@ import java.util.Map; private static final String TAG = "WebvttCueBuilder"; - private String id; private long startTime; private long endTime; private SpannableStringBuilder text; @@ -117,25 +96,13 @@ import java.util.Map; // Construction methods. public WebvttCue build() { - return build(Collections.emptyMap()); - } - - public WebvttCue build(Map styleMap) { - // TODO: Add support for inner spans. - maybeApplyStyleToText(styleMap.get(UNIVERSAL_CUE_ID), 0, text.length()); - maybeApplyStyleToText(styleMap.get(CUE_ID_PREFIX + id), 0, text.length()); if (position != Cue.DIMEN_UNSET && positionAnchor == Cue.TYPE_UNSET) { derivePositionAnchorFromAlignment(); } - return new WebvttCue(id, startTime, endTime, text, textAlignment, line, lineType, lineAnchor, + return new WebvttCue(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; @@ -209,54 +176,6 @@ import java.util.Map; 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; - } - } - } - } } 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 2635a2ee8c..6c08126a5b 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 @@ -20,12 +20,21 @@ import com.google.android.exoplayer.util.ParsableByteArray; import android.graphics.Typeface; 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.Map; import java.util.Stack; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -35,6 +44,8 @@ import java.util.regex.Pattern; */ /* package */ final class WebvttCueParser { + public static final String UNIVERSAL_CUE_ID = ""; + public static final String CUE_ID_PREFIX = "#"; public static final Pattern CUE_HEADER_PATTERN = Pattern .compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$"); @@ -76,22 +87,24 @@ import java.util.regex.Pattern; * * @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. * @return True if a valid Cue was found, false otherwise. */ - /* package */ boolean parseCue(ParsableByteArray webvttData, WebvttCue.Builder builder) { + /* package */ boolean parseCue(ParsableByteArray webvttData, WebvttCue.Builder builder, + Map styleMap) { 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); + return parseCue(null, cueHeaderMatcher, webvttData, builder, textBuilder, styleMap); } 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 parseCue(firstLine.trim(), cueHeaderMatcher, webvttData, builder, textBuilder, + styleMap); } } return false; @@ -131,10 +144,13 @@ import java.util.regex.Pattern; /** * Parses the text payload of a WebVTT Cue and applies modifications on {@link WebvttCue.Builder}. * + * @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 builder Target builder. */ - /* package */ static void parseCueText(String markup, WebvttCue.Builder builder) { + /* package */ static void parseCueText(String id, String markup, WebvttCue.Builder builder, + Map styleMap) { SpannableStringBuilder spannedText = new SpannableStringBuilder(); Stack startTagStack = new Stack<>(); String[] tagTokens; @@ -164,7 +180,7 @@ import java.util.regex.Pattern; break; } startTag = startTagStack.pop(); - applySpansForTag(startTag, spannedText); + applySpansForTag(startTag, spannedText, styleMap); } while(!startTag.name.equals(tagTokens[0])); } else if (!isVoidTag) { startTagStack.push(new StartTag(tagTokens[0], spannedText.length())); @@ -194,13 +210,15 @@ import java.util.regex.Pattern; } // apply unclosed tags while (!startTagStack.isEmpty()) { - applySpansForTag(startTagStack.pop(), spannedText); + applySpansForTag(startTagStack.pop(), spannedText, styleMap); } + applyStyleToText(spannedText, styleMap.get(UNIVERSAL_CUE_ID), 0, spannedText.length()); + applyStyleToText(spannedText, styleMap.get(CUE_ID_PREFIX + id), 0, spannedText.length()); builder.setText(spannedText); } - private static boolean parseCue(Matcher cueHeaderMatcher, ParsableByteArray webvttData, - WebvttCue.Builder builder, StringBuilder textBuilder) { + private static boolean parseCue(String id, Matcher cueHeaderMatcher, ParsableByteArray webvttData, + WebvttCue.Builder builder, StringBuilder textBuilder, Map styleMap) { try { // Parse the cue start and end times. builder.setStartTime(WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1))) @@ -221,7 +239,7 @@ import java.util.regex.Pattern; } textBuilder.append(line.trim()); } - parseCueText(textBuilder.toString(), builder); + parseCueText(id, textBuilder.toString(), builder, styleMap); return true; } @@ -333,22 +351,79 @@ import java.util.regex.Pattern; } } - private static void applySpansForTag(StartTag startTag, SpannableStringBuilder spannedText) { + private static void applySpansForTag(StartTag startTag, SpannableStringBuilder spannedText, + Map styleMap) { + WebvttCssStyle styleForTag = styleMap.get(startTag.name); + int start = startTag.position; + int end = spannedText.length(); 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: + spannedText.setSpan(new StyleSpan(STYLE_BOLD), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; + case TAG_ITALIC: + spannedText.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); + break; + case TAG_CLASS: + case TAG_LANG: + case TAG_VOICE: + break; + default: + return; + } + applyStyleToText(spannedText, styleForTag, start, end); + } + + private static void applyStyleToText(SpannableStringBuilder spannedText, + WebvttCssStyle style, int start, int end) { + if (style == null) { + return; + } + if (style.getStyle() != WebvttCssStyle.UNSPECIFIED) { + spannedText.setSpan(new StyleSpan(style.getStyle()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.isLinethrough()) { + spannedText.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.isUnderline()) { + spannedText.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.hasFontColor()) { + spannedText.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.hasBackgroundColor()) { + spannedText.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.getFontFamily() != null) { + spannedText.setSpan(new TypefaceSpan(style.getFontFamily()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.getTextAlign() != null) { + spannedText.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: + spannedText.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.FONT_SIZE_UNIT_EM: + spannedText.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT: + spannedText.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + } } } 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 d7b5c9fbbc..5586af0f90 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 @@ -76,8 +76,8 @@ public final class WebvttParser extends SubtitleParser { 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)); + if (cueParser.parseCue(parsableWebvttData, webvttCueBuilder, styleMap)) { + subtitles.add(webvttCueBuilder.build()); webvttCueBuilder.reset(); } }