blob: c8e9ed7ce0af092bcf9abca13b910394ccc19ef3 [file] [log] [blame]
/*
* Copyright (C) 2016 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.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.text.SpannableStringBuilder;
import android.util.Base64;
import android.util.Pair;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.text.Cue;
import com.google.android.exoplayer2.util.Assertions;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* A package internal representation of TTML node.
*/
/* package */ final class TtmlNode {
public static final String TAG_TT = "tt";
public static final String TAG_HEAD = "head";
public static final String TAG_BODY = "body";
public static final String TAG_DIV = "div";
public static final String TAG_P = "p";
public static final String TAG_SPAN = "span";
public static final String TAG_BR = "br";
public static final String TAG_STYLE = "style";
public static final String TAG_STYLING = "styling";
public static final String TAG_LAYOUT = "layout";
public static final String TAG_REGION = "region";
public static final String TAG_METADATA = "metadata";
public static final String TAG_IMAGE = "image";
public static final String TAG_DATA = "data";
public static final String TAG_INFORMATION = "information";
public static final String ANONYMOUS_REGION_ID = "";
public static final String ATTR_ID = "id";
public static final String ATTR_TTS_ORIGIN = "origin";
public static final String ATTR_TTS_EXTENT = "extent";
public static final String ATTR_TTS_DISPLAY_ALIGN = "displayAlign";
public static final String ATTR_TTS_BACKGROUND_COLOR = "backgroundColor";
public static final String ATTR_TTS_FONT_STYLE = "fontStyle";
public static final String ATTR_TTS_FONT_SIZE = "fontSize";
public static final String ATTR_TTS_FONT_FAMILY = "fontFamily";
public static final String ATTR_TTS_FONT_WEIGHT = "fontWeight";
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_ALIGN = "textAlign";
public static final String ATTR_TTS_TEXT_COMBINE = "textCombine";
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
public static final String LINETHROUGH = "linethrough";
public static final String NO_LINETHROUGH = "nolinethrough";
public static final String UNDERLINE = "underline";
public static final String NO_UNDERLINE = "nounderline";
public static final String ITALIC = "italic";
public static final String BOLD = "bold";
// Values for textAlign
public static final String LEFT = "left";
public static final String CENTER = "center";
public static final String RIGHT = "right";
public static final String START = "start";
public static final String END = "end";
// Values for textCombine
public static final String COMBINE_NONE = "none";
public static final String COMBINE_ALL = "all";
// Values for writingMode
public static final String VERTICAL = "tb";
public static final String VERTICAL_LR = "tblr";
public static final String VERTICAL_RL = "tbrl";
@Nullable public final String tag;
@Nullable public final String text;
public final boolean isTextNode;
public final long startTimeUs;
public final long endTimeUs;
@Nullable public final TtmlStyle style;
@Nullable private final String[] styleIds;
public final String regionId;
@Nullable public final String imageId;
@Nullable public final TtmlNode parent;
private final HashMap<String, Integer> nodeStartsByRegion;
private final HashMap<String, Integer> nodeEndsByRegion;
@MonotonicNonNull private List<TtmlNode> children;
public static TtmlNode buildTextNode(String text) {
return new TtmlNode(
/* tag= */ null,
TtmlRenderUtil.applyTextElementSpacePolicy(text),
/* startTimeUs= */ C.TIME_UNSET,
/* endTimeUs= */ C.TIME_UNSET,
/* style= */ null,
/* styleIds= */ null,
ANONYMOUS_REGION_ID,
/* imageId= */ null,
/* parent= */ null);
}
public static TtmlNode buildNode(
@Nullable String tag,
long startTimeUs,
long endTimeUs,
@Nullable TtmlStyle style,
@Nullable String[] styleIds,
String regionId,
@Nullable String imageId,
@Nullable TtmlNode parent) {
return new TtmlNode(
tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId, parent);
}
private TtmlNode(
@Nullable String tag,
@Nullable String text,
long startTimeUs,
long endTimeUs,
@Nullable TtmlStyle style,
@Nullable String[] styleIds,
String regionId,
@Nullable String imageId,
@Nullable TtmlNode parent) {
this.tag = tag;
this.text = text;
this.imageId = imageId;
this.style = style;
this.styleIds = styleIds;
this.isTextNode = text != null;
this.startTimeUs = startTimeUs;
this.endTimeUs = endTimeUs;
this.regionId = Assertions.checkNotNull(regionId);
this.parent = parent;
nodeStartsByRegion = new HashMap<>();
nodeEndsByRegion = new HashMap<>();
}
public boolean isActive(long timeUs) {
return (startTimeUs == C.TIME_UNSET && endTimeUs == C.TIME_UNSET)
|| (startTimeUs <= timeUs && endTimeUs == C.TIME_UNSET)
|| (startTimeUs == C.TIME_UNSET && timeUs < endTimeUs)
|| (startTimeUs <= timeUs && timeUs < endTimeUs);
}
public void addChild(TtmlNode child) {
if (children == null) {
children = new ArrayList<>();
}
children.add(child);
}
public TtmlNode getChild(int index) {
if (children == null) {
throw new IndexOutOfBoundsException();
}
return children.get(index);
}
public int getChildCount() {
return children == null ? 0 : children.size();
}
public long[] getEventTimesUs() {
TreeSet<Long> eventTimeSet = new TreeSet<>();
getEventTimes(eventTimeSet, false);
long[] eventTimes = new long[eventTimeSet.size()];
int i = 0;
for (long eventTimeUs : eventTimeSet) {
eventTimes[i++] = eventTimeUs;
}
return eventTimes;
}
private void getEventTimes(TreeSet<Long> out, boolean descendsPNode) {
boolean isPNode = TAG_P.equals(tag);
boolean isDivNode = TAG_DIV.equals(tag);
if (descendsPNode || isPNode || (isDivNode && imageId != null)) {
if (startTimeUs != C.TIME_UNSET) {
out.add(startTimeUs);
}
if (endTimeUs != C.TIME_UNSET) {
out.add(endTimeUs);
}
}
if (children == null) {
return;
}
for (int i = 0; i < children.size(); i++) {
children.get(i).getEventTimes(out, descendsPNode || isPNode);
}
}
@Nullable
public String[] getStyleIds() {
return styleIds;
}
public List<Cue> getCues(
long timeUs,
Map<String, TtmlStyle> globalStyles,
Map<String, TtmlRegion> regionMap,
Map<String, String> imageMap) {
List<Pair<String, String>> regionImageOutputs = new ArrayList<>();
traverseForImage(timeUs, regionId, regionImageOutputs);
TreeMap<String, Cue.Builder> regionTextOutputs = new TreeMap<>();
traverseForText(timeUs, false, regionId, regionTextOutputs);
traverseForStyle(timeUs, globalStyles, regionTextOutputs);
List<Cue> cues = new ArrayList<>();
// Create image based cues.
for (Pair<String, String> regionImagePair : regionImageOutputs) {
@Nullable String encodedBitmapData = imageMap.get(regionImagePair.second);
if (encodedBitmapData == null) {
// Image reference points to an invalid image. Do nothing.
continue;
}
byte[] bitmapData = Base64.decode(encodedBitmapData, Base64.DEFAULT);
Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, /* offset= */ 0, bitmapData.length);
TtmlRegion region = Assertions.checkNotNull(regionMap.get(regionImagePair.first));
cues.add(
new Cue.Builder()
.setBitmap(bitmap)
.setPosition(region.position)
.setPositionAnchor(Cue.ANCHOR_TYPE_START)
.setLine(region.line, Cue.LINE_TYPE_FRACTION)
.setLineAnchor(region.lineAnchor)
.setSize(region.width)
.setBitmapHeight(region.height)
.build());
}
// Create text based cues.
for (Map.Entry<String, Cue.Builder> entry : regionTextOutputs.entrySet()) {
TtmlRegion region = Assertions.checkNotNull(regionMap.get(entry.getKey()));
Cue.Builder regionOutput = entry.getValue();
cleanUpText((SpannableStringBuilder) Assertions.checkNotNull(regionOutput.getText()));
regionOutput.setLine(region.line, region.lineType);
regionOutput.setLineAnchor(region.lineAnchor);
regionOutput.setPosition(region.position);
regionOutput.setSize(region.width);
regionOutput.setTextSize(region.textSize, region.textSizeType);
cues.add(regionOutput.build());
}
return cues;
}
private void traverseForImage(
long timeUs, String inheritedRegion, List<Pair<String, String>> regionImageList) {
String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId;
if (isActive(timeUs) && TAG_DIV.equals(tag) && imageId != null) {
regionImageList.add(new Pair<>(resolvedRegionId, imageId));
return;
}
for (int i = 0; i < getChildCount(); ++i) {
getChild(i).traverseForImage(timeUs, resolvedRegionId, regionImageList);
}
}
private void traverseForText(
long timeUs,
boolean descendsPNode,
String inheritedRegion,
Map<String, Cue.Builder> regionOutputs) {
nodeStartsByRegion.clear();
nodeEndsByRegion.clear();
if (TAG_METADATA.equals(tag)) {
// Ignore metadata tag.
return;
}
String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId;
if (isTextNode && descendsPNode) {
getRegionOutputText(resolvedRegionId, regionOutputs).append(Assertions.checkNotNull(text));
} else if (TAG_BR.equals(tag) && descendsPNode) {
getRegionOutputText(resolvedRegionId, regionOutputs).append('\n');
} else if (isActive(timeUs)) {
// This is a container node, which can contain zero or more children.
for (Map.Entry<String, Cue.Builder> entry : regionOutputs.entrySet()) {
nodeStartsByRegion.put(
entry.getKey(), Assertions.checkNotNull(entry.getValue().getText()).length());
}
boolean isPNode = TAG_P.equals(tag);
for (int i = 0; i < getChildCount(); i++) {
getChild(i).traverseForText(timeUs, descendsPNode || isPNode, resolvedRegionId,
regionOutputs);
}
if (isPNode) {
TtmlRenderUtil.endParagraph(getRegionOutputText(resolvedRegionId, regionOutputs));
}
for (Map.Entry<String, Cue.Builder> entry : regionOutputs.entrySet()) {
nodeEndsByRegion.put(
entry.getKey(), Assertions.checkNotNull(entry.getValue().getText()).length());
}
}
}
private static SpannableStringBuilder getRegionOutputText(
String resolvedRegionId, Map<String, Cue.Builder> regionOutputs) {
if (!regionOutputs.containsKey(resolvedRegionId)) {
Cue.Builder regionOutput = new Cue.Builder();
regionOutput.setText(new SpannableStringBuilder());
regionOutputs.put(resolvedRegionId, regionOutput);
}
return (SpannableStringBuilder)
Assertions.checkNotNull(regionOutputs.get(resolvedRegionId).getText());
}
private void traverseForStyle(
long timeUs, Map<String, TtmlStyle> globalStyles, Map<String, Cue.Builder> regionOutputs) {
if (!isActive(timeUs)) {
return;
}
for (Map.Entry<String, Integer> entry : nodeEndsByRegion.entrySet()) {
String regionId = entry.getKey();
int start = nodeStartsByRegion.containsKey(regionId) ? nodeStartsByRegion.get(regionId) : 0;
int end = entry.getValue();
if (start != end) {
Cue.Builder regionOutput = Assertions.checkNotNull(regionOutputs.get(regionId));
applyStyleToOutput(globalStyles, regionOutput, start, end);
}
}
for (int i = 0; i < getChildCount(); ++i) {
getChild(i).traverseForStyle(timeUs, globalStyles, regionOutputs);
}
}
private void applyStyleToOutput(
Map<String, TtmlStyle> globalStyles, Cue.Builder regionOutput, int start, int end) {
@Nullable TtmlStyle resolvedStyle = TtmlRenderUtil.resolveStyle(style, styleIds, globalStyles);
@Nullable SpannableStringBuilder text = (SpannableStringBuilder) regionOutput.getText();
if (text == null) {
text = new SpannableStringBuilder();
regionOutput.setText(text);
}
if (resolvedStyle != null) {
TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent);
regionOutput.setVerticalType(resolvedStyle.getVerticalType());
}
}
private static void cleanUpText(SpannableStringBuilder builder) {
// Having joined the text elements, we need to do some final cleanup on the result.
// 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++) {
if (builder.charAt(i) == ' ') {
int j = i + 1;
while (j < builder.length() && builder.charAt(j) == ' ') {
j++;
}
int spacesToDelete = j - (i + 1);
if (spacesToDelete > 0) {
builder.delete(i, i + spacesToDelete);
}
}
}
// Remove any spaces from the start of each line.
if (builder.length() > 0 && builder.charAt(0) == ' ') {
builder.delete(0, 1);
}
for (int i = 0; i < builder.length() - 1; i++) {
if (builder.charAt(i) == '\n' && builder.charAt(i + 1) == ' ') {
builder.delete(i + 1, i + 2);
}
}
// Remove any spaces from the end of each line.
if (builder.length() > 0 && builder.charAt(builder.length() - 1) == ' ') {
builder.delete(builder.length() - 1, builder.length());
}
for (int i = 0; i < builder.length() - 1; i++) {
if (builder.charAt(i) == ' ' && builder.charAt(i + 1) == '\n') {
builder.delete(i, i + 1);
}
}
// Trim a trailing newline, if there is one.
if (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') {
builder.delete(builder.length() - 1, builder.length());
}
}
}