mirror of
https://github.com/androidx/media.git
synced 2025-05-17 12:39:52 +08:00
Add WebVTT support for ruby-position CSS property
This is currently only parsed if the CSS class is specified directly on the <ruby> tag (e.g. <ruby.myClass>) PiperOrigin-RevId: 312091710
This commit is contained in:
parent
3db703a983
commit
38fc7d8c0d
@ -17,6 +17,7 @@ package com.google.android.exoplayer2.text.webvtt;
|
|||||||
|
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import androidx.annotation.Nullable;
|
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.Assertions;
|
||||||
import com.google.android.exoplayer2.util.ColorParser;
|
import com.google.android.exoplayer2.util.ColorParser;
|
||||||
import com.google.android.exoplayer2.util.ParsableByteArray;
|
import com.google.android.exoplayer2.util.ParsableByteArray;
|
||||||
@ -39,6 +40,9 @@ import java.util.regex.Pattern;
|
|||||||
private static final String PROPERTY_COLOR = "color";
|
private static final String PROPERTY_COLOR = "color";
|
||||||
private static final String PROPERTY_FONT_FAMILY = "font-family";
|
private static final String PROPERTY_FONT_FAMILY = "font-family";
|
||||||
private static final String PROPERTY_FONT_WEIGHT = "font-weight";
|
private static final String PROPERTY_FONT_WEIGHT = "font-weight";
|
||||||
|
private static final String PROPERTY_RUBY_POSITION = "ruby-position";
|
||||||
|
private static final String VALUE_OVER = "over";
|
||||||
|
private static final String VALUE_UNDER = "under";
|
||||||
private static final String PROPERTY_TEXT_COMBINE_UPRIGHT = "text-combine-upright";
|
private static final String PROPERTY_TEXT_COMBINE_UPRIGHT = "text-combine-upright";
|
||||||
private static final String VALUE_ALL = "all";
|
private static final String VALUE_ALL = "all";
|
||||||
private static final String VALUE_DIGITS = "digits";
|
private static final String VALUE_DIGITS = "digits";
|
||||||
@ -186,6 +190,12 @@ import java.util.regex.Pattern;
|
|||||||
// At this point we have a presumably valid declaration, we need to parse it and fill the style.
|
// At this point we have a presumably valid declaration, we need to parse it and fill the style.
|
||||||
if (PROPERTY_COLOR.equals(property)) {
|
if (PROPERTY_COLOR.equals(property)) {
|
||||||
style.setFontColor(ColorParser.parseCssColor(value));
|
style.setFontColor(ColorParser.parseCssColor(value));
|
||||||
|
} else if (PROPERTY_RUBY_POSITION.equals(property)) {
|
||||||
|
if (VALUE_OVER.equals(value)) {
|
||||||
|
style.setRubyPosition(RubySpan.POSITION_OVER);
|
||||||
|
} else if (VALUE_UNDER.equals(value)) {
|
||||||
|
style.setRubyPosition(RubySpan.POSITION_UNDER);
|
||||||
|
}
|
||||||
} else if (PROPERTY_TEXT_COMBINE_UPRIGHT.equals(property)) {
|
} else if (PROPERTY_TEXT_COMBINE_UPRIGHT.equals(property)) {
|
||||||
style.setCombineUpright(VALUE_ALL.equals(value) || value.startsWith(VALUE_DIGITS));
|
style.setCombineUpright(VALUE_ALL.equals(value) || value.startsWith(VALUE_DIGITS));
|
||||||
} else if (PROPERTY_TEXT_DECORATION.equals(property)) {
|
} else if (PROPERTY_TEXT_DECORATION.equals(property)) {
|
||||||
|
@ -17,8 +17,10 @@ package com.google.android.exoplayer2.text.webvtt;
|
|||||||
|
|
||||||
import android.graphics.Typeface;
|
import android.graphics.Typeface;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import androidx.annotation.ColorInt;
|
||||||
import androidx.annotation.IntDef;
|
import androidx.annotation.IntDef;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import com.google.android.exoplayer2.text.span.RubySpan;
|
||||||
import com.google.android.exoplayer2.util.Util;
|
import com.google.android.exoplayer2.util.Util;
|
||||||
import java.lang.annotation.Documented;
|
import java.lang.annotation.Documented;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@ -83,7 +85,7 @@ public final class WebvttCssStyle {
|
|||||||
|
|
||||||
// Style properties.
|
// Style properties.
|
||||||
@Nullable private String fontFamily;
|
@Nullable private String fontFamily;
|
||||||
private int fontColor;
|
@ColorInt private int fontColor;
|
||||||
private boolean hasFontColor;
|
private boolean hasFontColor;
|
||||||
@OptionalBoolean private int linethrough;
|
@OptionalBoolean private int linethrough;
|
||||||
@OptionalBoolean private int underline;
|
@OptionalBoolean private int underline;
|
||||||
@ -91,6 +93,7 @@ public final class WebvttCssStyle {
|
|||||||
@OptionalBoolean private int italic;
|
@OptionalBoolean private int italic;
|
||||||
@FontSizeUnit private int fontSizeUnit;
|
@FontSizeUnit private int fontSizeUnit;
|
||||||
private float fontSize;
|
private float fontSize;
|
||||||
|
@RubySpan.Position private int rubyPosition;
|
||||||
private boolean combineUpright;
|
private boolean combineUpright;
|
||||||
|
|
||||||
// Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed
|
// Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed
|
||||||
@ -113,6 +116,7 @@ public final class WebvttCssStyle {
|
|||||||
bold = UNSPECIFIED;
|
bold = UNSPECIFIED;
|
||||||
italic = UNSPECIFIED;
|
italic = UNSPECIFIED;
|
||||||
fontSizeUnit = UNSPECIFIED;
|
fontSizeUnit = UNSPECIFIED;
|
||||||
|
rubyPosition = RubySpan.POSITION_UNKNOWN;
|
||||||
combineUpright = false;
|
combineUpright = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,8 +260,19 @@ public final class WebvttCssStyle {
|
|||||||
return fontSize;
|
return fontSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCombineUpright(boolean enabled) {
|
public WebvttCssStyle setRubyPosition(@RubySpan.Position int rubyPosition) {
|
||||||
|
this.rubyPosition = rubyPosition;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RubySpan.Position
|
||||||
|
public int getRubyPosition() {
|
||||||
|
return rubyPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebvttCssStyle setCombineUpright(boolean enabled) {
|
||||||
this.combineUpright = enabled;
|
this.combineUpright = enabled;
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean getCombineUpright() {
|
public boolean getCombineUpright() {
|
||||||
|
@ -538,6 +538,9 @@ public final class WebvttCueParser {
|
|||||||
List<StyleMatch> scratchStyleMatches) {
|
List<StyleMatch> scratchStyleMatches) {
|
||||||
int start = startTag.position;
|
int start = startTag.position;
|
||||||
int end = text.length();
|
int end = text.length();
|
||||||
|
scratchStyleMatches.clear();
|
||||||
|
getApplicableStyles(styles, cueId, startTag, scratchStyleMatches);
|
||||||
|
|
||||||
switch(startTag.name) {
|
switch(startTag.name) {
|
||||||
case TAG_BOLD:
|
case TAG_BOLD:
|
||||||
text.setSpan(new StyleSpan(STYLE_BOLD), start, end,
|
text.setSpan(new StyleSpan(STYLE_BOLD), start, end,
|
||||||
@ -548,7 +551,7 @@ public final class WebvttCueParser {
|
|||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
break;
|
break;
|
||||||
case TAG_RUBY:
|
case TAG_RUBY:
|
||||||
applyRubySpans(nestedElements, text, start);
|
applyRubySpans(text, start, nestedElements, scratchStyleMatches);
|
||||||
break;
|
break;
|
||||||
case TAG_UNDERLINE:
|
case TAG_UNDERLINE:
|
||||||
text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
@ -563,16 +566,25 @@ public final class WebvttCueParser {
|
|||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scratchStyleMatches.clear();
|
|
||||||
getApplicableStyles(styles, cueId, startTag, scratchStyleMatches);
|
for (int i = 0; i < scratchStyleMatches.size(); i++) {
|
||||||
int styleMatchesCount = scratchStyleMatches.size();
|
|
||||||
for (int i = 0; i < styleMatchesCount; i++) {
|
|
||||||
applyStyleToText(text, scratchStyleMatches.get(i).style, start, end);
|
applyStyleToText(text, scratchStyleMatches.get(i).style, start, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void applyRubySpans(
|
private static void applyRubySpans(
|
||||||
List<Element> nestedElements, SpannableStringBuilder text, int startTagPosition) {
|
SpannableStringBuilder text,
|
||||||
|
int startTagPosition,
|
||||||
|
List<Element> nestedElements,
|
||||||
|
List<StyleMatch> styleMatches) {
|
||||||
|
@RubySpan.Position int rubyPosition = RubySpan.POSITION_OVER;
|
||||||
|
for (int i = 0; i < styleMatches.size(); i++) {
|
||||||
|
WebvttCssStyle style = styleMatches.get(i).style;
|
||||||
|
if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) {
|
||||||
|
rubyPosition = style.getRubyPosition();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
List<Element> sortedNestedElements = new ArrayList<>(nestedElements.size());
|
List<Element> sortedNestedElements = new ArrayList<>(nestedElements.size());
|
||||||
sortedNestedElements.addAll(nestedElements);
|
sortedNestedElements.addAll(nestedElements);
|
||||||
Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC);
|
Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC);
|
||||||
@ -589,7 +601,7 @@ public final class WebvttCueParser {
|
|||||||
CharSequence rubyText = text.subSequence(adjustedRubyTextStart, adjustedRubyTextEnd);
|
CharSequence rubyText = text.subSequence(adjustedRubyTextStart, adjustedRubyTextEnd);
|
||||||
text.delete(adjustedRubyTextStart, adjustedRubyTextEnd);
|
text.delete(adjustedRubyTextStart, adjustedRubyTextEnd);
|
||||||
text.setSpan(
|
text.setSpan(
|
||||||
new RubySpan(rubyText.toString(), RubySpan.POSITION_OVER),
|
new RubySpan(rubyText.toString(), rubyPosition),
|
||||||
lastRubyTextEnd,
|
lastRubyTextEnd,
|
||||||
adjustedRubyTextStart,
|
adjustedRubyTextStart,
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
@ -877,7 +889,7 @@ public final class WebvttCueParser {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int compareTo(StyleMatch another) {
|
public int compareTo(StyleMatch another) {
|
||||||
return this.score - another.score;
|
return Integer.compare(this.score, another.score);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@ import static com.google.common.truth.Truth.assertThat;
|
|||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
import com.google.android.exoplayer2.text.span.RubySpan;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
@ -50,59 +49,6 @@ public final class WebvttCueParserTest {
|
|||||||
assertThat(text).hasNoSpans();
|
assertThat(text).hasNoSpans();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
public void parseRubyTag() throws Exception {
|
|
||||||
Spanned text =
|
|
||||||
parseCueText("Some <ruby>base text<rt>with ruby</rt></ruby> and undecorated text");
|
|
||||||
|
|
||||||
// The text between the <rt> tags is stripped from Cue.text and only present on the RubySpan.
|
|
||||||
assertThat(text.toString()).isEqualTo("Some base text and undecorated text");
|
|
||||||
assertThat(text)
|
|
||||||
.hasRubySpanBetween("Some ".length(), "Some base text".length())
|
|
||||||
.withTextAndPosition("with ruby", RubySpan.POSITION_OVER);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void parseSingleRubyTagWithMultipleRts() throws Exception {
|
|
||||||
Spanned text = parseCueText("<ruby>A<rt>1</rt>B<rt>2</rt>C<rt>3</rt></ruby>");
|
|
||||||
|
|
||||||
// The text between the <rt> tags is stripped from Cue.text and only present on the RubySpan.
|
|
||||||
assertThat(text.toString()).isEqualTo("ABC");
|
|
||||||
assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER);
|
|
||||||
assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER);
|
|
||||||
assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void parseMultipleRubyTagsWithSingleRtEach() throws Exception {
|
|
||||||
Spanned text =
|
|
||||||
parseCueText("<ruby>A<rt>1</rt></ruby><ruby>B<rt>2</rt></ruby><ruby>C<rt>3</rt></ruby>");
|
|
||||||
|
|
||||||
// The text between the <rt> tags is stripped from Cue.text and only present on the RubySpan.
|
|
||||||
assertThat(text.toString()).isEqualTo("ABC");
|
|
||||||
assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER);
|
|
||||||
assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER);
|
|
||||||
assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void parseRubyTagWithNoTextTag() throws Exception {
|
|
||||||
Spanned text = parseCueText("Some <ruby>base text with no ruby text</ruby>");
|
|
||||||
|
|
||||||
assertThat(text.toString()).isEqualTo("Some base text with no ruby text");
|
|
||||||
assertThat(text).hasNoSpans();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void parseRubyTagWithEmptyTextTag() throws Exception {
|
|
||||||
Spanned text = parseCueText("Some <ruby>base text with<rt></rt></ruby> empty ruby text");
|
|
||||||
|
|
||||||
assertThat(text.toString()).isEqualTo("Some base text with empty ruby text");
|
|
||||||
assertThat(text)
|
|
||||||
.hasRubySpanBetween("Some ".length(), "Some base text with".length())
|
|
||||||
.withTextAndPosition("", RubySpan.POSITION_OVER);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void parseDefaultTextColor() throws Exception {
|
public void parseDefaultTextColor() throws Exception {
|
||||||
Spanned text = parseCueText("In this sentence <c.red>this text</c> is red");
|
Spanned text = parseCueText("In this sentence <c.red>this text</c> is red");
|
||||||
|
@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
|
|||||||
import com.google.android.exoplayer2.testutil.TestUtil;
|
import com.google.android.exoplayer2.testutil.TestUtil;
|
||||||
import com.google.android.exoplayer2.text.Cue;
|
import com.google.android.exoplayer2.text.Cue;
|
||||||
import com.google.android.exoplayer2.text.SubtitleDecoderException;
|
import com.google.android.exoplayer2.text.SubtitleDecoderException;
|
||||||
|
import com.google.android.exoplayer2.text.span.RubySpan;
|
||||||
import com.google.android.exoplayer2.util.Assertions;
|
import com.google.android.exoplayer2.util.Assertions;
|
||||||
import com.google.android.exoplayer2.util.ColorParser;
|
import com.google.android.exoplayer2.util.ColorParser;
|
||||||
import com.google.common.collect.Iterables;
|
import com.google.common.collect.Iterables;
|
||||||
@ -48,6 +49,7 @@ public class WebvttDecoderTest {
|
|||||||
private static final String WITH_OVERLAPPING_TIMESTAMPS_FILE =
|
private static final String WITH_OVERLAPPING_TIMESTAMPS_FILE =
|
||||||
"webvtt/with_overlapping_timestamps";
|
"webvtt/with_overlapping_timestamps";
|
||||||
private static final String WITH_VERTICAL_FILE = "webvtt/with_vertical";
|
private static final String WITH_VERTICAL_FILE = "webvtt/with_vertical";
|
||||||
|
private static final String WITH_RUBIES_FILE = "webvtt/with_rubies";
|
||||||
private static final String WITH_BAD_CUE_HEADER_FILE = "webvtt/with_bad_cue_header";
|
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_TAGS_FILE = "webvtt/with_tags";
|
||||||
private static final String WITH_CSS_STYLES = "webvtt/with_css_styles";
|
private static final String WITH_CSS_STYLES = "webvtt/with_css_styles";
|
||||||
@ -345,6 +347,51 @@ public class WebvttDecoderTest {
|
|||||||
assertThat(thirdCue.verticalType).isEqualTo(Cue.TYPE_UNSET);
|
assertThat(thirdCue.verticalType).isEqualTo(Cue.TYPE_UNSET);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void decodeWithRubies() throws Exception {
|
||||||
|
WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_RUBIES_FILE);
|
||||||
|
|
||||||
|
assertThat(subtitle.getEventTimeCount()).isEqualTo(8);
|
||||||
|
|
||||||
|
// Check that an explicit `over` position is read from CSS.
|
||||||
|
Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0)));
|
||||||
|
assertThat(firstCue.text.toString()).isEqualTo("Some text with over-ruby.");
|
||||||
|
assertThat((Spanned) firstCue.text)
|
||||||
|
.hasRubySpanBetween("Some ".length(), "Some text with over-ruby".length())
|
||||||
|
.withTextAndPosition("over", RubySpan.POSITION_OVER);
|
||||||
|
|
||||||
|
// Check that `under` is read from CSS and unspecified defaults to `over`.
|
||||||
|
Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2)));
|
||||||
|
assertThat(secondCue.text.toString())
|
||||||
|
.isEqualTo("Some text with under-ruby and over-ruby (default).");
|
||||||
|
assertThat((Spanned) secondCue.text)
|
||||||
|
.hasRubySpanBetween("Some ".length(), "Some text with under-ruby".length())
|
||||||
|
.withTextAndPosition("under", RubySpan.POSITION_UNDER);
|
||||||
|
assertThat((Spanned) secondCue.text)
|
||||||
|
.hasRubySpanBetween(
|
||||||
|
"Some text with under-ruby and ".length(),
|
||||||
|
"Some text with under-ruby and over-ruby (default)".length())
|
||||||
|
.withTextAndPosition("over", RubySpan.POSITION_OVER);
|
||||||
|
|
||||||
|
// Check many <rt> tags nested in a single <ruby> span.
|
||||||
|
Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4)));
|
||||||
|
assertThat(thirdCue.text.toString()).isEqualTo("base1base2base3.");
|
||||||
|
assertThat((Spanned) thirdCue.text)
|
||||||
|
.hasRubySpanBetween(/* start= */ 0, "base1".length())
|
||||||
|
.withTextAndPosition("text1", RubySpan.POSITION_OVER);
|
||||||
|
assertThat((Spanned) thirdCue.text)
|
||||||
|
.hasRubySpanBetween("base1".length(), "base1base2".length())
|
||||||
|
.withTextAndPosition("text2", RubySpan.POSITION_OVER);
|
||||||
|
assertThat((Spanned) thirdCue.text)
|
||||||
|
.hasRubySpanBetween("base1base2".length(), "base1base2base3".length())
|
||||||
|
.withTextAndPosition("text3", RubySpan.POSITION_OVER);
|
||||||
|
|
||||||
|
// Check a <ruby> span with no <rt> tags.
|
||||||
|
Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6)));
|
||||||
|
assertThat(fourthCue.text.toString()).isEqualTo("Some text with no ruby text.");
|
||||||
|
assertThat((Spanned) fourthCue.text).hasNoSpans();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void decodeWithBadCueHeader() throws Exception {
|
public void decodeWithBadCueHeader() throws Exception {
|
||||||
WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_BAD_CUE_HEADER_FILE);
|
WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_BAD_CUE_HEADER_FILE);
|
||||||
|
25
testdata/src/test/assets/webvtt/with_rubies
vendored
Normal file
25
testdata/src/test/assets/webvtt/with_rubies
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
STYLE
|
||||||
|
::cue(.under) {
|
||||||
|
ruby-position: under;
|
||||||
|
}
|
||||||
|
|
||||||
|
STYLE
|
||||||
|
::cue(.over) {
|
||||||
|
ruby-position: over;
|
||||||
|
}
|
||||||
|
|
||||||
|
00:00:01.000 --> 00:00:02.000
|
||||||
|
Some <ruby.over>text with over-ruby<rt>over</rt></ruby>.
|
||||||
|
|
||||||
|
00:00:03.000 --> 00:00:04.000
|
||||||
|
Some <ruby.under>text with under-ruby<rt>under</rt></ruby> and <ruby>over-ruby (default)<rt>over</rt></ruby>.
|
||||||
|
|
||||||
|
NOTE Many individual rubies in a single <ruby> tag
|
||||||
|
|
||||||
|
00:00:05.000 --> 00:00:06.000
|
||||||
|
<ruby>base1<rt>text1</rt>base2<rt>text2</rt>base3<rt>text3</rt></ruby>.
|
||||||
|
|
||||||
|
00:00:07.000 --> 00:00:08.000
|
||||||
|
Some <ruby>text with no ruby text</ruby>.
|
Loading…
x
Reference in New Issue
Block a user