mirror of
https://github.com/samsonjs/media.git
synced 2026-04-27 15:07:40 +00:00
Add ruby support to TtmlDecoder
I had to expand the info that gets passed around a bit, so I could join up the container, base & text ruby nodes. Spec: https://www.w3.org/TR/2018/REC-ttml2-20181108/#style-attribute-ruby PiperOrigin-RevId: 295931653
This commit is contained in:
parent
4107375c9d
commit
786a1ee82f
9 changed files with 371 additions and 14 deletions
|
|
@ -49,6 +49,8 @@
|
||||||
* Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing,
|
* Catch-and-log all fatal exceptions in `TextRenderer` instead of re-throwing,
|
||||||
allowing playback to continue even if subtitles fail
|
allowing playback to continue even if subtitles fail
|
||||||
([#6885](https://github.com/google/ExoPlayer/issues/6885)).
|
([#6885](https://github.com/google/ExoPlayer/issues/6885)).
|
||||||
|
* Parse `tts:ruby` and `tts:rubyPosition` properties in TTML subtitles
|
||||||
|
(rendering is coming later).
|
||||||
* DRM:
|
* DRM:
|
||||||
* Add support for attaching DRM sessions to clear content in the demo app.
|
* Add support for attaching DRM sessions to clear content in the demo app.
|
||||||
* Remove `DrmSessionManager` references from all renderers.
|
* Remove `DrmSessionManager` references from all renderers.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* 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.text.ttml;
|
||||||
|
|
||||||
|
import android.text.Spanned;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A span used to mark a section of text for later deletion.
|
||||||
|
*
|
||||||
|
* <p>This is deliberately package-private because it's not generally supported by Android and
|
||||||
|
* results in surprising behaviour when simply calling {@link Spanned#toString} (i.e. the text isn't
|
||||||
|
* deleted).
|
||||||
|
*
|
||||||
|
* <p>This span is explicitly handled in {@code TtmlNode#cleanUpText}.
|
||||||
|
*/
|
||||||
|
/* package */ final class DeleteTextSpan {}
|
||||||
|
|
@ -22,6 +22,7 @@ import com.google.android.exoplayer2.text.Cue;
|
||||||
import com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
|
import com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
|
||||||
import com.google.android.exoplayer2.text.Subtitle;
|
import com.google.android.exoplayer2.text.Subtitle;
|
||||||
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.android.exoplayer2.util.Log;
|
import com.google.android.exoplayer2.util.Log;
|
||||||
|
|
@ -537,6 +538,40 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case TtmlNode.ATTR_TTS_RUBY:
|
||||||
|
switch (Util.toLowerInvariant(attributeValue)) {
|
||||||
|
case TtmlNode.RUBY_CONTAINER:
|
||||||
|
style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_CONTAINER);
|
||||||
|
break;
|
||||||
|
case TtmlNode.RUBY_BASE:
|
||||||
|
case TtmlNode.RUBY_BASE_CONTAINER:
|
||||||
|
style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_BASE);
|
||||||
|
break;
|
||||||
|
case TtmlNode.RUBY_TEXT:
|
||||||
|
case TtmlNode.RUBY_TEXT_CONTAINER:
|
||||||
|
style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_TEXT);
|
||||||
|
break;
|
||||||
|
case TtmlNode.RUBY_DELIMITER:
|
||||||
|
style = createIfNull(style).setRubyType(TtmlStyle.RUBY_TYPE_DELIMITER);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TtmlNode.ATTR_TTS_RUBY_POSITION:
|
||||||
|
switch (Util.toLowerInvariant(attributeValue)) {
|
||||||
|
case TtmlNode.RUBY_BEFORE:
|
||||||
|
style = createIfNull(style).setRubyPosition(RubySpan.POSITION_OVER);
|
||||||
|
break;
|
||||||
|
case TtmlNode.RUBY_AFTER:
|
||||||
|
style = createIfNull(style).setRubyPosition(RubySpan.POSITION_UNDER);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case TtmlNode.ATTR_TTS_TEXT_DECORATION:
|
case TtmlNode.ATTR_TTS_TEXT_DECORATION:
|
||||||
switch (Util.toLowerInvariant(attributeValue)) {
|
switch (Util.toLowerInvariant(attributeValue)) {
|
||||||
case TtmlNode.LINETHROUGH:
|
case TtmlNode.LINETHROUGH:
|
||||||
|
|
@ -650,8 +685,9 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder {
|
||||||
endTime = parent.endTimeUs;
|
endTime = parent.endTimeUs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return TtmlNode.buildNode(
|
return TtmlNode.buildNode(
|
||||||
parser.getName(), startTime, endTime, style, styleIds, regionId, imageId);
|
parser.getName(), startTime, endTime, style, styleIds, regionId, imageId, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isSupportedTag(String tag) {
|
private static boolean isSupportedTag(String tag) {
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
public static final String ATTR_TTS_FONT_FAMILY = "fontFamily";
|
public static final String ATTR_TTS_FONT_FAMILY = "fontFamily";
|
||||||
public static final String ATTR_TTS_FONT_WEIGHT = "fontWeight";
|
public static final String ATTR_TTS_FONT_WEIGHT = "fontWeight";
|
||||||
public static final String ATTR_TTS_COLOR = "color";
|
public static final String ATTR_TTS_COLOR = "color";
|
||||||
|
public static final String ATTR_TTS_RUBY = "ruby";
|
||||||
|
public static final String ATTR_TTS_RUBY_POSITION = "rubyPosition";
|
||||||
public static final String ATTR_TTS_TEXT_DECORATION = "textDecoration";
|
public static final String ATTR_TTS_TEXT_DECORATION = "textDecoration";
|
||||||
public static final String ATTR_TTS_TEXT_ALIGN = "textAlign";
|
public static final String ATTR_TTS_TEXT_ALIGN = "textAlign";
|
||||||
public static final String ATTR_TTS_TEXT_COMBINE = "textCombine";
|
public static final String ATTR_TTS_TEXT_COMBINE = "textCombine";
|
||||||
public static final String ATTR_TTS_WRITING_MODE = "writingMode";
|
public static final String ATTR_TTS_WRITING_MODE = "writingMode";
|
||||||
|
|
||||||
|
// Values for ruby
|
||||||
|
public static final String RUBY_CONTAINER = "container";
|
||||||
|
public static final String RUBY_BASE = "base";
|
||||||
|
public static final String RUBY_BASE_CONTAINER = "baseContainer";
|
||||||
|
public static final String RUBY_TEXT = "text";
|
||||||
|
public static final String RUBY_TEXT_CONTAINER = "textContainer";
|
||||||
|
public static final String RUBY_DELIMITER = "delimiter";
|
||||||
|
|
||||||
|
// Values for rubyPosition
|
||||||
|
public static final String RUBY_BEFORE = "before";
|
||||||
|
public static final String RUBY_AFTER = "after";
|
||||||
// Values for textDecoration
|
// Values for textDecoration
|
||||||
public static final String LINETHROUGH = "linethrough";
|
public static final String LINETHROUGH = "linethrough";
|
||||||
public static final String NO_LINETHROUGH = "nolinethrough";
|
public static final String NO_LINETHROUGH = "nolinethrough";
|
||||||
|
|
@ -102,6 +115,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
@Nullable private final String[] styleIds;
|
@Nullable private final String[] styleIds;
|
||||||
public final String regionId;
|
public final String regionId;
|
||||||
@Nullable public final String imageId;
|
@Nullable public final String imageId;
|
||||||
|
@Nullable public final TtmlNode parent;
|
||||||
|
|
||||||
private final HashMap<String, Integer> nodeStartsByRegion;
|
private final HashMap<String, Integer> nodeStartsByRegion;
|
||||||
private final HashMap<String, Integer> nodeEndsByRegion;
|
private final HashMap<String, Integer> nodeEndsByRegion;
|
||||||
|
|
@ -117,7 +131,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
/* style= */ null,
|
/* style= */ null,
|
||||||
/* styleIds= */ null,
|
/* styleIds= */ null,
|
||||||
ANONYMOUS_REGION_ID,
|
ANONYMOUS_REGION_ID,
|
||||||
/* imageId= */ null);
|
/* imageId= */ null,
|
||||||
|
/* parent= */ null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TtmlNode buildNode(
|
public static TtmlNode buildNode(
|
||||||
|
|
@ -127,9 +142,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
@Nullable TtmlStyle style,
|
@Nullable TtmlStyle style,
|
||||||
@Nullable String[] styleIds,
|
@Nullable String[] styleIds,
|
||||||
String regionId,
|
String regionId,
|
||||||
@Nullable String imageId) {
|
@Nullable String imageId,
|
||||||
|
@Nullable TtmlNode parent) {
|
||||||
return new TtmlNode(
|
return new TtmlNode(
|
||||||
tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId);
|
tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TtmlNode(
|
private TtmlNode(
|
||||||
|
|
@ -140,7 +156,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
@Nullable TtmlStyle style,
|
@Nullable TtmlStyle style,
|
||||||
@Nullable String[] styleIds,
|
@Nullable String[] styleIds,
|
||||||
String regionId,
|
String regionId,
|
||||||
@Nullable String imageId) {
|
@Nullable String imageId,
|
||||||
|
@Nullable TtmlNode parent) {
|
||||||
this.tag = tag;
|
this.tag = tag;
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.imageId = imageId;
|
this.imageId = imageId;
|
||||||
|
|
@ -150,6 +167,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
this.startTimeUs = startTimeUs;
|
this.startTimeUs = startTimeUs;
|
||||||
this.endTimeUs = endTimeUs;
|
this.endTimeUs = endTimeUs;
|
||||||
this.regionId = Assertions.checkNotNull(regionId);
|
this.regionId = Assertions.checkNotNull(regionId);
|
||||||
|
this.parent = parent;
|
||||||
nodeStartsByRegion = new HashMap<>();
|
nodeStartsByRegion = new HashMap<>();
|
||||||
nodeEndsByRegion = new HashMap<>();
|
nodeEndsByRegion = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
@ -361,14 +379,19 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
regionOutput.setText(text);
|
regionOutput.setText(text);
|
||||||
}
|
}
|
||||||
if (resolvedStyle != null) {
|
if (resolvedStyle != null) {
|
||||||
TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle);
|
TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent);
|
||||||
regionOutput.setVerticalType(resolvedStyle.getVerticalType());
|
regionOutput.setVerticalType(resolvedStyle.getVerticalType());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void cleanUpText(SpannableStringBuilder builder) {
|
private static void cleanUpText(SpannableStringBuilder builder) {
|
||||||
// Having joined the text elements, we need to do some final cleanup on the result.
|
// Having joined the text elements, we need to do some final cleanup on the result.
|
||||||
// 1. Collapse multiple consecutive spaces into a single space.
|
// Remove any text covered by a DeleteTextSpan (e.g. ruby text).
|
||||||
|
DeleteTextSpan[] deleteTextSpans = builder.getSpans(0, builder.length(), DeleteTextSpan.class);
|
||||||
|
for (DeleteTextSpan deleteTextSpan : deleteTextSpans) {
|
||||||
|
builder.replace(builder.getSpanStart(deleteTextSpan), builder.getSpanEnd(deleteTextSpan), "");
|
||||||
|
}
|
||||||
|
// Collapse multiple consecutive spaces into a single space.
|
||||||
for (int i = 0; i < builder.length(); i++) {
|
for (int i = 0; i < builder.length(); i++) {
|
||||||
if (builder.charAt(i) == ' ') {
|
if (builder.charAt(i) == ' ') {
|
||||||
int j = i + 1;
|
int j = i + 1;
|
||||||
|
|
@ -381,7 +404,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Remove any spaces from the start of each line.
|
// Remove any spaces from the start of each line.
|
||||||
if (builder.length() > 0 && builder.charAt(0) == ' ') {
|
if (builder.length() > 0 && builder.charAt(0) == ' ') {
|
||||||
builder.delete(0, 1);
|
builder.delete(0, 1);
|
||||||
}
|
}
|
||||||
|
|
@ -390,7 +413,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
builder.delete(i + 1, i + 2);
|
builder.delete(i + 1, i + 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3. Remove any spaces from the end of each line.
|
// Remove any spaces from the end of each line.
|
||||||
if (builder.length() > 0 && builder.charAt(builder.length() - 1) == ' ') {
|
if (builder.length() > 0 && builder.charAt(builder.length() - 1) == ' ') {
|
||||||
builder.delete(builder.length() - 1, builder.length());
|
builder.delete(builder.length() - 1, builder.length());
|
||||||
}
|
}
|
||||||
|
|
@ -399,7 +422,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
builder.delete(i, i + 1);
|
builder.delete(i, i + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 4. Trim a trailing newline, if there is one.
|
// Trim a trailing newline, if there is one.
|
||||||
if (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') {
|
if (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') {
|
||||||
builder.delete(builder.length() - 1, builder.length());
|
builder.delete(builder.length() - 1, builder.length());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
package com.google.android.exoplayer2.text.ttml;
|
package com.google.android.exoplayer2.text.ttml;
|
||||||
|
|
||||||
import android.text.Layout.Alignment;
|
import android.text.Layout.Alignment;
|
||||||
|
import android.text.Spannable;
|
||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
import android.text.style.AbsoluteSizeSpan;
|
import android.text.style.AbsoluteSizeSpan;
|
||||||
|
|
@ -29,7 +30,12 @@ import android.text.style.TypefaceSpan;
|
||||||
import android.text.style.UnderlineSpan;
|
import android.text.style.UnderlineSpan;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan;
|
import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan;
|
||||||
|
import com.google.android.exoplayer2.text.span.RubySpan;
|
||||||
import com.google.android.exoplayer2.text.span.SpanUtil;
|
import com.google.android.exoplayer2.text.span.SpanUtil;
|
||||||
|
import com.google.android.exoplayer2.util.Log;
|
||||||
|
import com.google.android.exoplayer2.util.Util;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Deque;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,6 +43,8 @@ import java.util.Map;
|
||||||
*/
|
*/
|
||||||
/* package */ final class TtmlRenderUtil {
|
/* package */ final class TtmlRenderUtil {
|
||||||
|
|
||||||
|
private static final String TAG = "TtmlRenderUtil";
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static TtmlStyle resolveStyle(
|
public static TtmlStyle resolveStyle(
|
||||||
@Nullable TtmlStyle style, @Nullable String[] styleIds, Map<String, TtmlStyle> globalStyles) {
|
@Nullable TtmlStyle style, @Nullable String[] styleIds, Map<String, TtmlStyle> globalStyles) {
|
||||||
|
|
@ -71,8 +79,8 @@ import java.util.Map;
|
||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void applyStylesToSpan(SpannableStringBuilder builder,
|
public static void applyStylesToSpan(
|
||||||
int start, int end, TtmlStyle style) {
|
Spannable builder, int start, int end, TtmlStyle style, @Nullable TtmlNode parent) {
|
||||||
|
|
||||||
if (style.getStyle() != TtmlStyle.UNSPECIFIED) {
|
if (style.getStyle() != TtmlStyle.UNSPECIFIED) {
|
||||||
builder.setSpan(new StyleSpan(style.getStyle()), start, end,
|
builder.setSpan(new StyleSpan(style.getStyle()), start, end,
|
||||||
|
|
@ -108,6 +116,53 @@ import java.util.Map;
|
||||||
end,
|
end,
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
}
|
}
|
||||||
|
switch (style.getRubyType()) {
|
||||||
|
case TtmlStyle.RUBY_TYPE_BASE:
|
||||||
|
// look for the sibling RUBY_TEXT and add it as span between start & end.
|
||||||
|
@Nullable TtmlNode containerNode = findRubyContainerNode(parent);
|
||||||
|
if (containerNode == null) {
|
||||||
|
// No matching container node
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
@Nullable TtmlNode textNode = findRubyTextNode(containerNode);
|
||||||
|
if (textNode == null) {
|
||||||
|
// no matching text node
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String rubyText;
|
||||||
|
if (textNode.getChildCount() == 1 && textNode.getChild(0).text != null) {
|
||||||
|
rubyText = Util.castNonNull(textNode.getChild(0).text);
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Skipping rubyText node without exactly one text child.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Get rubyPosition from `textNode` when TTML inheritance is implemented.
|
||||||
|
@RubySpan.Position
|
||||||
|
int rubyPosition =
|
||||||
|
containerNode.style != null
|
||||||
|
? containerNode.style.getRubyPosition()
|
||||||
|
: RubySpan.POSITION_UNKNOWN;
|
||||||
|
builder.setSpan(
|
||||||
|
new RubySpan(rubyText, rubyPosition), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
break;
|
||||||
|
case TtmlStyle.RUBY_TYPE_DELIMITER:
|
||||||
|
// TODO: Add support for this when RubySpan supports parenthetical text. For now, just
|
||||||
|
// fall through and delete the text.
|
||||||
|
case TtmlStyle.RUBY_TYPE_TEXT:
|
||||||
|
// We can't just remove the text directly from `builder` here because TtmlNode has fixed
|
||||||
|
// ideas of where every node starts and ends (nodeStartsByRegion and nodeEndsByRegion) so
|
||||||
|
// all these indices become invalid if we mutate the underlying string at this point.
|
||||||
|
// Instead we add a special span that's then handled in TtmlNode#cleanUpText.
|
||||||
|
builder.setSpan(new DeleteTextSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
break;
|
||||||
|
case TtmlStyle.RUBY_TYPE_CONTAINER:
|
||||||
|
case TtmlStyle.UNSPECIFIED:
|
||||||
|
default:
|
||||||
|
// Do nothing
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable Alignment textAlign = style.getTextAlign();
|
@Nullable Alignment textAlign = style.getTextAlign();
|
||||||
if (textAlign != null) {
|
if (textAlign != null) {
|
||||||
SpanUtil.addOrReplaceSpan(
|
SpanUtil.addOrReplaceSpan(
|
||||||
|
|
@ -156,6 +211,35 @@ import java.util.Map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static TtmlNode findRubyTextNode(TtmlNode rubyContainerNode) {
|
||||||
|
Deque<TtmlNode> childNodesStack = new ArrayDeque<>();
|
||||||
|
childNodesStack.push(rubyContainerNode);
|
||||||
|
while (!childNodesStack.isEmpty()) {
|
||||||
|
TtmlNode childNode = childNodesStack.pop();
|
||||||
|
if (childNode.style != null && childNode.style.getRubyType() == TtmlStyle.RUBY_TYPE_TEXT) {
|
||||||
|
return childNode;
|
||||||
|
}
|
||||||
|
for (int i = childNode.getChildCount() - 1; i >= 0; i--) {
|
||||||
|
childNodesStack.push(childNode.getChild(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static TtmlNode findRubyContainerNode(@Nullable TtmlNode node) {
|
||||||
|
while (node != null) {
|
||||||
|
@Nullable TtmlStyle style = node.style;
|
||||||
|
if (style != null && style.getRubyType() == TtmlStyle.RUBY_TYPE_CONTAINER) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the end of a paragraph is encountered. Adds a newline if there are one or more
|
* Called when the end of a paragraph is encountered. Adds a newline if there are one or more
|
||||||
* non-space characters since the previous newline.
|
* non-space characters since the previous newline.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.annotation.IntDef;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import com.google.android.exoplayer2.text.Cue;
|
import com.google.android.exoplayer2.text.Cue;
|
||||||
import com.google.android.exoplayer2.text.Cue.VerticalType;
|
import com.google.android.exoplayer2.text.Cue.VerticalType;
|
||||||
|
import com.google.android.exoplayer2.text.span.RubySpan;
|
||||||
import java.lang.annotation.Documented;
|
import java.lang.annotation.Documented;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
|
@ -61,6 +62,16 @@ import java.lang.annotation.RetentionPolicy;
|
||||||
private static final int OFF = 0;
|
private static final int OFF = 0;
|
||||||
private static final int ON = 1;
|
private static final int ON = 1;
|
||||||
|
|
||||||
|
@Documented
|
||||||
|
@Retention(RetentionPolicy.SOURCE)
|
||||||
|
@IntDef({UNSPECIFIED, RUBY_TYPE_CONTAINER, RUBY_TYPE_BASE, RUBY_TYPE_TEXT, RUBY_TYPE_DELIMITER})
|
||||||
|
public @interface RubyType {}
|
||||||
|
|
||||||
|
public static final int RUBY_TYPE_CONTAINER = 1;
|
||||||
|
public static final int RUBY_TYPE_BASE = 2;
|
||||||
|
public static final int RUBY_TYPE_TEXT = 3;
|
||||||
|
public static final int RUBY_TYPE_DELIMITER = 4;
|
||||||
|
|
||||||
@Nullable private String fontFamily;
|
@Nullable private String fontFamily;
|
||||||
private int fontColor;
|
private int fontColor;
|
||||||
private boolean hasFontColor;
|
private boolean hasFontColor;
|
||||||
|
|
@ -73,6 +84,8 @@ import java.lang.annotation.RetentionPolicy;
|
||||||
@FontSizeUnit private int fontSizeUnit;
|
@FontSizeUnit private int fontSizeUnit;
|
||||||
private float fontSize;
|
private float fontSize;
|
||||||
@Nullable private String id;
|
@Nullable private String id;
|
||||||
|
@RubyType private int rubyType;
|
||||||
|
@RubySpan.Position private int rubyPosition;
|
||||||
@Nullable private Layout.Alignment textAlign;
|
@Nullable private Layout.Alignment textAlign;
|
||||||
@OptionalBoolean private int textCombine;
|
@OptionalBoolean private int textCombine;
|
||||||
@Cue.VerticalType private int verticalType;
|
@Cue.VerticalType private int verticalType;
|
||||||
|
|
@ -83,6 +96,8 @@ import java.lang.annotation.RetentionPolicy;
|
||||||
bold = UNSPECIFIED;
|
bold = UNSPECIFIED;
|
||||||
italic = UNSPECIFIED;
|
italic = UNSPECIFIED;
|
||||||
fontSizeUnit = UNSPECIFIED;
|
fontSizeUnit = UNSPECIFIED;
|
||||||
|
rubyType = UNSPECIFIED;
|
||||||
|
rubyPosition = RubySpan.POSITION_UNKNOWN;
|
||||||
textCombine = UNSPECIFIED;
|
textCombine = UNSPECIFIED;
|
||||||
verticalType = Cue.TYPE_UNSET;
|
verticalType = Cue.TYPE_UNSET;
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +229,9 @@ import java.lang.annotation.RetentionPolicy;
|
||||||
if (underline == UNSPECIFIED) {
|
if (underline == UNSPECIFIED) {
|
||||||
underline = ancestor.underline;
|
underline = ancestor.underline;
|
||||||
}
|
}
|
||||||
|
if (rubyPosition == RubySpan.POSITION_UNKNOWN) {
|
||||||
|
rubyPosition = ancestor.rubyPosition;
|
||||||
|
}
|
||||||
if (textAlign == null && ancestor.textAlign != null) {
|
if (textAlign == null && ancestor.textAlign != null) {
|
||||||
textAlign = ancestor.textAlign;
|
textAlign = ancestor.textAlign;
|
||||||
}
|
}
|
||||||
|
|
@ -228,8 +246,11 @@ import java.lang.annotation.RetentionPolicy;
|
||||||
if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) {
|
if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) {
|
||||||
setBackgroundColor(ancestor.backgroundColor);
|
setBackgroundColor(ancestor.backgroundColor);
|
||||||
}
|
}
|
||||||
if (chaining && verticalType == Cue.TYPE_UNSET) {
|
if (chaining && rubyType == UNSPECIFIED && ancestor.rubyType != UNSPECIFIED) {
|
||||||
verticalType = ancestor.verticalType;
|
rubyType = ancestor.rubyType;
|
||||||
|
}
|
||||||
|
if (chaining && verticalType == Cue.TYPE_UNSET && ancestor.verticalType != Cue.TYPE_UNSET) {
|
||||||
|
setVerticalType(ancestor.verticalType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -245,6 +266,26 @@ import java.lang.annotation.RetentionPolicy;
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TtmlStyle setRubyType(@RubyType int rubyType) {
|
||||||
|
this.rubyType = rubyType;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RubyType
|
||||||
|
public int getRubyType() {
|
||||||
|
return rubyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TtmlStyle setRubyPosition(@RubySpan.Position int position) {
|
||||||
|
this.rubyPosition = position;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RubySpan.Position
|
||||||
|
public int getRubyPosition() {
|
||||||
|
return rubyPosition;
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public Layout.Alignment getTextAlign() {
|
public Layout.Alignment getTextAlign() {
|
||||||
return textAlign;
|
return textAlign;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ 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.Subtitle;
|
import com.google.android.exoplayer2.text.Subtitle;
|
||||||
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 java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
@ -61,6 +62,7 @@ public final class TtmlDecoderTest {
|
||||||
private static final String BITMAP_UNSUPPORTED_REGION_FILE = "ttml/bitmap_unsupported_region.xml";
|
private static final String BITMAP_UNSUPPORTED_REGION_FILE = "ttml/bitmap_unsupported_region.xml";
|
||||||
private static final String VERTICAL_TEXT_FILE = "ttml/vertical_text.xml";
|
private static final String VERTICAL_TEXT_FILE = "ttml/vertical_text.xml";
|
||||||
private static final String TEXT_COMBINE_FILE = "ttml/text_combine.xml";
|
private static final String TEXT_COMBINE_FILE = "ttml/text_combine.xml";
|
||||||
|
private static final String RUBIES_FILE = "ttml/rubies.xml";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testInlineAttributes() throws IOException, SubtitleDecoderException {
|
public void testInlineAttributes() throws IOException, SubtitleDecoderException {
|
||||||
|
|
@ -606,6 +608,38 @@ public final class TtmlDecoderTest {
|
||||||
assertThat(thirdCue).hasNoHorizontalTextInVerticalContextSpanBetween(0, thirdCue.length());
|
assertThat(thirdCue).hasNoHorizontalTextInVerticalContextSpanBetween(0, thirdCue.length());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRubies() throws IOException, SubtitleDecoderException {
|
||||||
|
TtmlSubtitle subtitle = getSubtitle(RUBIES_FILE);
|
||||||
|
|
||||||
|
Spanned firstCue = getOnlyCueTextAtTimeUs(subtitle, 10_000_000);
|
||||||
|
assertThat(firstCue.toString()).isEqualTo("Cue with annotated text.");
|
||||||
|
assertThat(firstCue)
|
||||||
|
.hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length())
|
||||||
|
.withTextAndPosition("1st rubies", RubySpan.POSITION_OVER);
|
||||||
|
assertThat(firstCue)
|
||||||
|
.hasRubySpanBetween("Cue with annotated ".length(), "Cue with annotated text".length())
|
||||||
|
.withTextAndPosition("2nd rubies", RubySpan.POSITION_UNKNOWN);
|
||||||
|
|
||||||
|
Spanned secondCue = getOnlyCueTextAtTimeUs(subtitle, 20_000_000);
|
||||||
|
assertThat(secondCue.toString()).isEqualTo("Cue with annotated text.");
|
||||||
|
assertThat(secondCue)
|
||||||
|
.hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length())
|
||||||
|
.withTextAndPosition("rubies", RubySpan.POSITION_UNKNOWN);
|
||||||
|
|
||||||
|
Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000);
|
||||||
|
assertThat(thirdCue.toString()).isEqualTo("Cue with annotated text.");
|
||||||
|
assertThat(thirdCue).hasNoRubySpanBetween(0, thirdCue.length());
|
||||||
|
|
||||||
|
Spanned fourthCue = getOnlyCueTextAtTimeUs(subtitle, 40_000_000);
|
||||||
|
assertThat(fourthCue.toString()).isEqualTo("Cue with text.");
|
||||||
|
assertThat(fourthCue).hasNoRubySpanBetween(0, fourthCue.length());
|
||||||
|
|
||||||
|
Spanned fifthCue = getOnlyCueTextAtTimeUs(subtitle, 50_000_000);
|
||||||
|
assertThat(fifthCue.toString()).isEqualTo("Cue with annotated text.");
|
||||||
|
assertThat(fifthCue).hasNoRubySpanBetween(0, fifthCue.length());
|
||||||
|
}
|
||||||
|
|
||||||
private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) {
|
private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) {
|
||||||
Cue cue = getOnlyCueAtTimeUs(subtitle, timeUs);
|
Cue cue = getOnlyCueAtTimeUs(subtitle, timeUs);
|
||||||
assertThat(cue.text).isInstanceOf(Spanned.class);
|
assertThat(cue.text).isInstanceOf(Spanned.class);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import android.text.Layout;
|
||||||
import androidx.annotation.ColorInt;
|
import androidx.annotation.ColorInt;
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
import com.google.android.exoplayer2.text.Cue;
|
import com.google.android.exoplayer2.text.Cue;
|
||||||
|
import com.google.android.exoplayer2.text.span.RubySpan;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
|
@ -42,6 +43,8 @@ public final class TtmlStyleTest {
|
||||||
private static final float FONT_SIZE = 12.5f;
|
private static final float FONT_SIZE = 12.5f;
|
||||||
@TtmlStyle.FontSizeUnit private static final int FONT_SIZE_UNIT = TtmlStyle.FONT_SIZE_UNIT_EM;
|
@TtmlStyle.FontSizeUnit private static final int FONT_SIZE_UNIT = TtmlStyle.FONT_SIZE_UNIT_EM;
|
||||||
@ColorInt private static final int BACKGROUND_COLOR = Color.BLACK;
|
@ColorInt private static final int BACKGROUND_COLOR = Color.BLACK;
|
||||||
|
private static final int RUBY_TYPE = TtmlStyle.RUBY_TYPE_TEXT;
|
||||||
|
private static final int RUBY_POSITION = RubySpan.POSITION_UNDER;
|
||||||
private static final Layout.Alignment TEXT_ALIGN = Layout.Alignment.ALIGN_CENTER;
|
private static final Layout.Alignment TEXT_ALIGN = Layout.Alignment.ALIGN_CENTER;
|
||||||
private static final boolean TEXT_COMBINE = true;
|
private static final boolean TEXT_COMBINE = true;
|
||||||
@Cue.VerticalType private static final int VERTICAL_TYPE = Cue.VERTICAL_TYPE_RL;
|
@Cue.VerticalType private static final int VERTICAL_TYPE = Cue.VERTICAL_TYPE_RL;
|
||||||
|
|
@ -58,6 +61,8 @@ public final class TtmlStyleTest {
|
||||||
.setFontFamily(FONT_FAMILY)
|
.setFontFamily(FONT_FAMILY)
|
||||||
.setFontSize(FONT_SIZE)
|
.setFontSize(FONT_SIZE)
|
||||||
.setFontSizeUnit(FONT_SIZE_UNIT)
|
.setFontSizeUnit(FONT_SIZE_UNIT)
|
||||||
|
.setRubyType(RUBY_TYPE)
|
||||||
|
.setRubyPosition(RUBY_POSITION)
|
||||||
.setTextAlign(TEXT_ALIGN)
|
.setTextAlign(TEXT_ALIGN)
|
||||||
.setTextCombine(TEXT_COMBINE)
|
.setTextCombine(TEXT_COMBINE)
|
||||||
.setVerticalType(VERTICAL_TYPE);
|
.setVerticalType(VERTICAL_TYPE);
|
||||||
|
|
@ -75,8 +80,12 @@ public final class TtmlStyleTest {
|
||||||
assertThat(style.getFontColor()).isEqualTo(FONT_COLOR);
|
assertThat(style.getFontColor()).isEqualTo(FONT_COLOR);
|
||||||
assertThat(style.getFontSize()).isEqualTo(FONT_SIZE);
|
assertThat(style.getFontSize()).isEqualTo(FONT_SIZE);
|
||||||
assertThat(style.getFontSizeUnit()).isEqualTo(FONT_SIZE_UNIT);
|
assertThat(style.getFontSizeUnit()).isEqualTo(FONT_SIZE_UNIT);
|
||||||
|
assertThat(style.getRubyPosition()).isEqualTo(RUBY_POSITION);
|
||||||
assertThat(style.getTextAlign()).isEqualTo(TEXT_ALIGN);
|
assertThat(style.getTextAlign()).isEqualTo(TEXT_ALIGN);
|
||||||
assertThat(style.getTextCombine()).isEqualTo(TEXT_COMBINE);
|
assertThat(style.getTextCombine()).isEqualTo(TEXT_COMBINE);
|
||||||
|
assertWithMessage("rubyType should not be inherited")
|
||||||
|
.that(style.getRubyType())
|
||||||
|
.isEqualTo(UNSPECIFIED);
|
||||||
assertWithMessage("backgroundColor should not be inherited")
|
assertWithMessage("backgroundColor should not be inherited")
|
||||||
.that(style.hasBackgroundColor())
|
.that(style.hasBackgroundColor())
|
||||||
.isFalse();
|
.isFalse();
|
||||||
|
|
@ -99,11 +108,13 @@ public final class TtmlStyleTest {
|
||||||
assertThat(style.getFontColor()).isEqualTo(FONT_COLOR);
|
assertThat(style.getFontColor()).isEqualTo(FONT_COLOR);
|
||||||
assertThat(style.getFontSize()).isEqualTo(FONT_SIZE);
|
assertThat(style.getFontSize()).isEqualTo(FONT_SIZE);
|
||||||
assertThat(style.getFontSizeUnit()).isEqualTo(FONT_SIZE_UNIT);
|
assertThat(style.getFontSizeUnit()).isEqualTo(FONT_SIZE_UNIT);
|
||||||
|
assertThat(style.getRubyPosition()).isEqualTo(RUBY_POSITION);
|
||||||
assertThat(style.getTextAlign()).isEqualTo(TEXT_ALIGN);
|
assertThat(style.getTextAlign()).isEqualTo(TEXT_ALIGN);
|
||||||
assertThat(style.getTextCombine()).isEqualTo(TEXT_COMBINE);
|
assertThat(style.getTextCombine()).isEqualTo(TEXT_COMBINE);
|
||||||
assertWithMessage("backgroundColor should be chained")
|
assertWithMessage("backgroundColor should be chained")
|
||||||
.that(style.getBackgroundColor())
|
.that(style.getBackgroundColor())
|
||||||
.isEqualTo(BACKGROUND_COLOR);
|
.isEqualTo(BACKGROUND_COLOR);
|
||||||
|
assertWithMessage("rubyType should be chained").that(style.getRubyType()).isEqualTo(RUBY_TYPE);
|
||||||
assertWithMessage("verticalType should be chained")
|
assertWithMessage("verticalType should be chained")
|
||||||
.that(style.getVerticalType())
|
.that(style.getVerticalType())
|
||||||
.isEqualTo(VERTICAL_TYPE);
|
.isEqualTo(VERTICAL_TYPE);
|
||||||
|
|
@ -206,6 +217,24 @@ public final class TtmlStyleTest {
|
||||||
assertThat(style.getId()).isNull();
|
assertThat(style.getId()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRubyType() {
|
||||||
|
TtmlStyle style = new TtmlStyle();
|
||||||
|
|
||||||
|
assertThat(style.getRubyType()).isEqualTo(UNSPECIFIED);
|
||||||
|
style.setRubyType(TtmlStyle.RUBY_TYPE_BASE);
|
||||||
|
assertThat(style.getRubyType()).isEqualTo(TtmlStyle.RUBY_TYPE_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRubyPosition() {
|
||||||
|
TtmlStyle style = new TtmlStyle();
|
||||||
|
|
||||||
|
assertThat(style.getRubyPosition()).isEqualTo(RubySpan.POSITION_UNKNOWN);
|
||||||
|
style.setRubyPosition(RubySpan.POSITION_OVER);
|
||||||
|
assertThat(style.getRubyPosition()).isEqualTo(RubySpan.POSITION_OVER);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testTextAlign() {
|
public void testTextAlign() {
|
||||||
TtmlStyle style = new TtmlStyle();
|
TtmlStyle style = new TtmlStyle();
|
||||||
|
|
|
||||||
78
testdata/src/test/assets/ttml/rubies.xml
vendored
Normal file
78
testdata/src/test/assets/ttml/rubies.xml
vendored
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<!--
|
||||||
|
~ 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.
|
||||||
|
~
|
||||||
|
-->
|
||||||
|
<tt xmlns:ttm="http://www.w3.org/2006/10/ttaf1#metadata"
|
||||||
|
xmlns:ttp="http://www.w3.org/2006/10/ttaf1#parameter"
|
||||||
|
xmlns:tts="http://www.w3.org/2006/10/ttaf1#style"
|
||||||
|
xmlns="http://www.w3.org/ns/ttml"
|
||||||
|
xmlns="http://www.w3.org/2006/10/ttaf1">
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<!-- Base before and after text, one with explicit position -->
|
||||||
|
<p begin="10s" end="18s">
|
||||||
|
Cue with
|
||||||
|
<span tts:ruby="container" tts:rubyPosition="before">
|
||||||
|
<span tts:ruby="base">annotated</span>
|
||||||
|
<span tts:ruby="text">1st rubies</span>
|
||||||
|
</span>
|
||||||
|
<span tts:ruby="container">
|
||||||
|
<span tts:ruby="text">2nd rubies</span>
|
||||||
|
<span tts:ruby="base">text</span>.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- Delimiter (parenthetical) text is stripped -->
|
||||||
|
<p begin="20s" end="28s">
|
||||||
|
Cue with
|
||||||
|
<span tts:ruby="container">
|
||||||
|
<span tts:ruby="text">rubies</span>
|
||||||
|
<span tts:ruby="base">annotated</span>
|
||||||
|
<span tts:ruby="delimiter">alt-text</span>
|
||||||
|
</span>
|
||||||
|
text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- No text section -> no span -->
|
||||||
|
<p begin="30s" end="38s">
|
||||||
|
Cue with
|
||||||
|
<span tts:ruby="container" tts:rubyPosition="before">
|
||||||
|
<span tts:ruby="base">annotated</span>
|
||||||
|
</span>
|
||||||
|
text.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- No base section -> text still stripped-->
|
||||||
|
<p begin="40s" end="48s">
|
||||||
|
Cue with
|
||||||
|
<span tts:ruby="container" tts:rubyPosition="before">
|
||||||
|
<span tts:ruby="text">rubies</span>
|
||||||
|
</span>
|
||||||
|
text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- No container section -> text still stripped-->
|
||||||
|
<p begin="50s" end="58s">
|
||||||
|
Cue with
|
||||||
|
<span tts:ruby="text">rubies</span>
|
||||||
|
<span tts:ruby="base">annotated</span>
|
||||||
|
text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</tt>
|
||||||
Loading…
Reference in a new issue