mirror of
https://github.com/androidx/media.git
synced 2025-05-16 20:19:57 +08:00
Start generating HTML from Span-styling in SubtitleWebView
PiperOrigin-RevId: 298565231
This commit is contained in:
parent
eb3ea92806
commit
7bfd2b27eb
@ -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 <a
|
||||
* href="https://developer.android.com/guide/topics/text/spans">span-styled text</a> to HTML.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<Transition> 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<Transition> findSpanTransitions(Spanned spanned) {
|
||||
SparseArray<Transition> 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(
|
||||
"<span style='color:%s;'>", toCssColor(colorSpan.getForegroundColor()));
|
||||
} else if (span instanceof StyleSpan) {
|
||||
switch (((StyleSpan) span).getStyle()) {
|
||||
case Typeface.BOLD:
|
||||
return "<b>";
|
||||
case Typeface.ITALIC:
|
||||
return "<i>";
|
||||
case Typeface.BOLD_ITALIC:
|
||||
return "<b><i>";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else if (span instanceof RubySpan) {
|
||||
RubySpan rubySpan = (RubySpan) span;
|
||||
switch (rubySpan.position) {
|
||||
case RubySpan.POSITION_OVER:
|
||||
return "<ruby style='ruby-position:over;'>";
|
||||
case RubySpan.POSITION_UNDER:
|
||||
return "<ruby style='ruby-position:under;'>";
|
||||
case RubySpan.POSITION_UNKNOWN:
|
||||
return "<ruby style='ruby-position:unset;'>";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else if (span instanceof UnderlineSpan) {
|
||||
return "<u>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String getClosingTag(Object span) {
|
||||
if (span instanceof ForegroundColorSpan) {
|
||||
return "</span>";
|
||||
} else if (span instanceof StyleSpan) {
|
||||
switch (((StyleSpan) span).getStyle()) {
|
||||
case Typeface.BOLD:
|
||||
return "</b>";
|
||||
case Typeface.ITALIC:
|
||||
return "</i>";
|
||||
case Typeface.BOLD_ITALIC:
|
||||
return "</i></b>";
|
||||
}
|
||||
} else if (span instanceof RubySpan) {
|
||||
RubySpan rubySpan = (RubySpan) span;
|
||||
return "<rt>" + rubySpan.rubyText + "</rt></ruby>";
|
||||
} else if (span instanceof UnderlineSpan) {
|
||||
return "</u>";
|
||||
}
|
||||
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<Transition> 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<SpanInfo> 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<SpanInfo> 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<SpanInfo> spansAdded;
|
||||
private final List<SpanInfo> spansRemoved;
|
||||
|
||||
public Transition() {
|
||||
this.spansAdded = new ArrayList<>();
|
||||
this.spansRemoved = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
@ -153,7 +153,7 @@ import java.util.List;
|
||||
if (i > 0) {
|
||||
cueText.append("<br>");
|
||||
}
|
||||
cueText.append(cues.get(i).text);
|
||||
cueText.append(SpannedToHtmlConverter.convert(cues.get(i).text));
|
||||
}
|
||||
webView.loadData(
|
||||
"<html><body><p style=\"color:red;font-size:20px;height:150px\">"
|
||||
|
@ -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 <span style='color:rgba(64,32,16,0.502);'>colored</span> 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 <b>bold</b>, <i>italic</i> and <b><i>bold-italic</i></b> 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 <ruby style='ruby-position:over;'>over-annotated<rt>ruby-text</rt></ruby>"
|
||||
+ " 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"
|
||||
+ " <ruby style='ruby-position:under;'>under-annotated<rt>ruby-text</rt></ruby>"
|
||||
+ " 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 <u>underlined</u> section.");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void convert_escapesHtmlInUnspannedString() {
|
||||
String html = SpannedToHtmlConverter.convert("String with <b>bold</b> tags");
|
||||
|
||||
assertThat(html).isEqualTo("String with <b>bold</b> tags");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void convert_escapesUnrecognisedTagInSpannedString() {
|
||||
SpannableString spanned = new SpannableString("String with <foo>unrecognised</foo> tags");
|
||||
spanned.setSpan(
|
||||
new StyleSpan(Typeface.ITALIC),
|
||||
"String with ".length(),
|
||||
"String with <foo>unrecognised</foo>".length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
String html = SpannedToHtmlConverter.convert(spanned);
|
||||
|
||||
assertThat(html).isEqualTo("String with <i><foo>unrecognised</foo></i> 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 <b><i><u>italic-bold-underlined</u></i></b> 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 <i>italic and <b>bold</b></i> section");
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user