From 7bfd2b27eba6b9db854d8f71259bfc1b972dd6ca Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 3 Mar 2020 11:08:19 +0000 Subject: [PATCH] Start generating HTML from Span-styling in SubtitleWebView PiperOrigin-RevId: 298565231 --- .../exoplayer2/ui/SpannedToHtmlConverter.java | 236 ++++++++++++++++++ .../exoplayer2/ui/SubtitleWebView.java | 2 +- .../ui/SpannedToHtmlConverterTest.java | 198 +++++++++++++++ 3 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java create mode 100644 library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java new file mode 100644 index 0000000000..59d5a234fb --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2020 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.exoplayer2.ui; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Html; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.SparseArray; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Utility class to convert from span-styled text to HTML. + * + *

Supports all of the spans used by ExoPlayer's subtitle decoders, including custom ones found + * in {@link com.google.android.exoplayer2.text.span}. + */ +// TODO: Add support for more span types - only a small selection are currently implemented. +/* package */ final class SpannedToHtmlConverter { + + private SpannedToHtmlConverter() {} + + /** + * Convert {@code text} into HTML, adding tags and styling to match any styling spans present. + * + *

All textual content is HTML-escaped during the conversion. + */ + public static String convert(@Nullable CharSequence text) { + if (text == null) { + return ""; + } + if (!(text instanceof Spanned)) { + return Html.escapeHtml(text); + } + Spanned spanned = (Spanned) text; + SparseArray spanTransitions = findSpanTransitions(spanned); + + StringBuilder html = new StringBuilder(spanned.length()); + int previousTransition = 0; + for (int i = 0; i < spanTransitions.size(); i++) { + int index = spanTransitions.keyAt(i); + html.append(Html.escapeHtml(spanned.subSequence(previousTransition, index))); + + Transition transition = spanTransitions.get(index); + Collections.sort(transition.spansRemoved, SpanInfo.FOR_CLOSING_TAGS); + for (SpanInfo spanInfo : transition.spansRemoved) { + html.append(spanInfo.closingTag); + } + Collections.sort(transition.spansAdded, SpanInfo.FOR_OPENING_TAGS); + for (SpanInfo spanInfo : transition.spansAdded) { + html.append(spanInfo.openingTag); + } + previousTransition = index; + } + + html.append(Html.escapeHtml(spanned.subSequence(previousTransition, spanned.length()))); + + return html.toString(); + } + + private static SparseArray findSpanTransitions(Spanned spanned) { + SparseArray spanTransitions = new SparseArray<>(); + + for (Object span : spanned.getSpans(0, spanned.length(), Object.class)) { + @Nullable String openingTag = getOpeningTag(span); + @Nullable String closingTag = getClosingTag(span); + int spanStart = spanned.getSpanStart(span); + int spanEnd = spanned.getSpanEnd(span); + if (openingTag != null) { + Assertions.checkNotNull(closingTag); + SpanInfo spanInfo = new SpanInfo(spanStart, spanEnd, openingTag, closingTag); + getOrCreate(spanTransitions, spanStart).spansAdded.add(spanInfo); + getOrCreate(spanTransitions, spanEnd).spansRemoved.add(spanInfo); + } + } + + return spanTransitions; + } + + @Nullable + private static String getOpeningTag(Object span) { + if (span instanceof ForegroundColorSpan) { + ForegroundColorSpan colorSpan = (ForegroundColorSpan) span; + return Util.formatInvariant( + "", toCssColor(colorSpan.getForegroundColor())); + } else if (span instanceof StyleSpan) { + switch (((StyleSpan) span).getStyle()) { + case Typeface.BOLD: + return ""; + case Typeface.ITALIC: + return ""; + case Typeface.BOLD_ITALIC: + return ""; + default: + return null; + } + } else if (span instanceof RubySpan) { + RubySpan rubySpan = (RubySpan) span; + switch (rubySpan.position) { + case RubySpan.POSITION_OVER: + return ""; + case RubySpan.POSITION_UNDER: + return ""; + case RubySpan.POSITION_UNKNOWN: + return ""; + default: + return null; + } + } else if (span instanceof UnderlineSpan) { + return ""; + } else { + return null; + } + } + + @Nullable + private static String getClosingTag(Object span) { + if (span instanceof ForegroundColorSpan) { + return ""; + } else if (span instanceof StyleSpan) { + switch (((StyleSpan) span).getStyle()) { + case Typeface.BOLD: + return ""; + case Typeface.ITALIC: + return ""; + case Typeface.BOLD_ITALIC: + return ""; + } + } else if (span instanceof RubySpan) { + RubySpan rubySpan = (RubySpan) span; + return "" + rubySpan.rubyText + ""; + } else if (span instanceof UnderlineSpan) { + return ""; + } + return null; + } + + private static String toCssColor(@ColorInt int color) { + return Util.formatInvariant( + "rgba(%d,%d,%d,%.3f)", + Color.red(color), Color.green(color), Color.blue(color), Color.alpha(color) / 255.0); + } + + private static Transition getOrCreate(SparseArray transitions, int key) { + @Nullable Transition transition = transitions.get(key); + if (transition == null) { + transition = new Transition(); + transitions.put(key, transition); + } + return transition; + } + + private static final class SpanInfo { + /** + * Sort by end index (descending), then by opening tag and then closing tag (both ascending, for + * determinism). + */ + private static final Comparator FOR_OPENING_TAGS = + (info1, info2) -> { + int result = Integer.compare(info2.end, info1.end); + if (result != 0) { + return result; + } + result = info1.openingTag.compareTo(info2.openingTag); + if (result != 0) { + return result; + } + return info1.closingTag.compareTo(info2.closingTag); + }; + + /** + * Sort by start index (descending), then by opening tag and then closing tag (both descending, + * for determinism). + */ + private static final Comparator FOR_CLOSING_TAGS = + (info1, info2) -> { + int result = Integer.compare(info2.start, info1.start); + if (result != 0) { + return result; + } + result = info2.openingTag.compareTo(info1.openingTag); + if (result != 0) { + return result; + } + return info2.closingTag.compareTo(info1.closingTag); + }; + + public final int start; + public final int end; + public final String openingTag; + public final String closingTag; + + private SpanInfo(int start, int end, String openingTag, String closingTag) { + this.start = start; + this.end = end; + this.openingTag = openingTag; + this.closingTag = closingTag; + } + } + + private static final class Transition { + private final List spansAdded; + private final List spansRemoved; + + public Transition() { + this.spansAdded = new ArrayList<>(); + this.spansRemoved = new ArrayList<>(); + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java index 7fa6e4165a..91f3ecef04 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java @@ -153,7 +153,7 @@ import java.util.List; if (i > 0) { cueText.append("
"); } - cueText.append(cues.get(i).text); + cueText.append(SpannedToHtmlConverter.convert(cues.get(i).text)); } webView.loadData( "

" diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java new file mode 100644 index 0000000000..382798025e --- /dev/null +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2020 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.exoplayer2.ui; + +import static com.google.common.truth.Truth.assertThat; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.text.span.RubySpan; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link SpannedToHtmlConverter}. */ +@RunWith(AndroidJUnit4.class) +public class SpannedToHtmlConverterTest { + + @Test + public void convert_supportsForegroundColorSpan() { + SpannableString spanned = new SpannableString("String with colored section"); + spanned.setSpan( + new ForegroundColorSpan(Color.argb(128, 64, 32, 16)), + "String with ".length(), + "String with colored".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html) + .isEqualTo("String with colored section"); + } + + @Test + public void convert_supportsStyleSpan() { + SpannableString spanned = + new SpannableString("String with bold, italic and bold-italic sections."); + spanned.setSpan( + new StyleSpan(Typeface.BOLD), + "String with ".length(), + "String with bold".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spanned.setSpan( + new StyleSpan(Typeface.ITALIC), + "String with bold, ".length(), + "String with bold, italic".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spanned.setSpan( + new StyleSpan(Typeface.BOLD_ITALIC), + "String with bold, italic and ".length(), + "String with bold, italic and bold-italic".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html) + .isEqualTo( + "String with bold, italic and bold-italic sections."); + } + + @Test + public void convert_supportsRubySpan_over() { + SpannableString spanned = new SpannableString("String with over-annotated section"); + spanned.setSpan( + new RubySpan("ruby-text", RubySpan.POSITION_OVER), + "String with ".length(), + "String with over-annotated".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html) + .isEqualTo( + "String with over-annotatedruby-text" + + " section"); + } + + @Test + public void convert_supportsRubySpan_under() { + SpannableString spanned = new SpannableString("String with under-annotated section"); + spanned.setSpan( + new RubySpan("ruby-text", RubySpan.POSITION_UNDER), + "String with ".length(), + "String with under-annotated".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html) + .isEqualTo( + "String with" + + " under-annotatedruby-text" + + " section"); + } + + @Test + public void convert_supportsUnderlineSpan() { + SpannableString spanned = new SpannableString("String with underlined section."); + spanned.setSpan( + new UnderlineSpan(), + "String with ".length(), + "String with underlined".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html).isEqualTo("String with underlined section."); + } + + @Test + public void convert_escapesHtmlInUnspannedString() { + String html = SpannedToHtmlConverter.convert("String with bold tags"); + + assertThat(html).isEqualTo("String with <b>bold</b> tags"); + } + + @Test + public void convert_escapesUnrecognisedTagInSpannedString() { + SpannableString spanned = new SpannableString("String with unrecognised tags"); + spanned.setSpan( + new StyleSpan(Typeface.ITALIC), + "String with ".length(), + "String with unrecognised".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html).isEqualTo("String with <foo>unrecognised</foo> tags"); + } + + @Test + public void convert_ignoresUnrecognisedSpan() { + SpannableString spanned = new SpannableString("String with unrecognised span"); + spanned.setSpan( + new Object() { + @Override + public String toString() { + return "Force an anonymous class to be created"; + } + }, + "String with ".length(), + "String with unrecognised".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html).isEqualTo("String with unrecognised span"); + } + + @Test + public void convert_sortsTagsConsistently() { + SpannableString spanned = new SpannableString("String with italic-bold-underlined section"); + int start = "String with ".length(); + int end = "String with italic-bold-underlined".length(); + spanned.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spanned.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spanned.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html).isEqualTo("String with italic-bold-underlined section"); + } + + @Test + public void convert_supportsNestedTags() { + SpannableString spanned = new SpannableString("String with italic and bold section"); + int start = "String with ".length(); + int end = "String with italic and bold".length(); + spanned.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spanned.setSpan( + new StyleSpan(Typeface.BOLD), + "String with italic and ".length(), + "String with italic and bold".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + String html = SpannedToHtmlConverter.convert(spanned); + + assertThat(html).isEqualTo("String with italic and bold section"); + } +}