/* * 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 androidx.media3.extractor.text.ttml; import static java.lang.Math.max; import static java.lang.Math.min; import android.text.Layout; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.text.Cue; import androidx.media3.common.text.TextAnnotation; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.ColorParser; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.common.util.XmlPullParserUtil; import androidx.media3.extractor.text.SimpleSubtitleDecoder; import androidx.media3.extractor.text.Subtitle; import androidx.media3.extractor.text.SubtitleDecoderException; import com.google.common.base.Ascii; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.ArrayDeque; import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.checkerframework.checker.nullness.qual.PolyNull; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; /** * A {@link SimpleSubtitleDecoder} for TTML supporting the DFXP presentation profile. Features * supported by this decoder are: * * * * @see TTML specification */ @UnstableApi public final class TtmlDecoder extends SimpleSubtitleDecoder { private static final String TAG = "TtmlDecoder"; private static final String TTP = "http://www.w3.org/ns/ttml#parameter"; private static final String ATTR_BEGIN = "begin"; private static final String ATTR_DURATION = "dur"; private static final String ATTR_END = "end"; private static final String ATTR_STYLE = "style"; private static final String ATTR_REGION = "region"; private static final String ATTR_IMAGE = "backgroundImage"; private static final Pattern CLOCK_TIME = Pattern.compile( "^([0-9][0-9]+):([0-9][0-9]):([0-9][0-9])" + "(?:(\\.[0-9]+)|:([0-9][0-9])(?:\\.([0-9]+))?)?$"); private static final Pattern OFFSET_TIME = Pattern.compile("^([0-9]+(?:\\.[0-9]+)?)(h|m|s|ms|f|t)$"); private static final Pattern FONT_SIZE = Pattern.compile("^(([0-9]*.)?[0-9]+)(px|em|%)$"); static final Pattern SIGNED_PERCENTAGE = Pattern.compile("^([-+]?\\d+\\.?\\d*?)%$"); static final Pattern PERCENTAGE_COORDINATES = Pattern.compile("^(\\d+\\.?\\d*?)% (\\d+\\.?\\d*?)%$"); private static final Pattern PIXEL_COORDINATES = Pattern.compile("^(\\d+\\.?\\d*?)px (\\d+\\.?\\d*?)px$"); private static final Pattern CELL_RESOLUTION = Pattern.compile("^(\\d+) (\\d+)$"); private static final int DEFAULT_FRAME_RATE = 30; private static final FrameAndTickRate DEFAULT_FRAME_AND_TICK_RATE = new FrameAndTickRate(DEFAULT_FRAME_RATE, 1, 1); private static final CellResolution DEFAULT_CELL_RESOLUTION = new CellResolution(/* columns= */ 32, /* rows= */ 15); private final XmlPullParserFactory xmlParserFactory; public TtmlDecoder() { super("TtmlDecoder"); try { xmlParserFactory = XmlPullParserFactory.newInstance(); xmlParserFactory.setNamespaceAware(true); } catch (XmlPullParserException e) { throw new RuntimeException("Couldn't create XmlPullParserFactory instance", e); } } @Override protected Subtitle decode(byte[] bytes, int length, boolean reset) throws SubtitleDecoderException { try { XmlPullParser xmlParser = xmlParserFactory.newPullParser(); Map globalStyles = new HashMap<>(); Map regionMap = new HashMap<>(); Map imageMap = new HashMap<>(); regionMap.put(TtmlNode.ANONYMOUS_REGION_ID, new TtmlRegion(TtmlNode.ANONYMOUS_REGION_ID)); ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes, 0, length); xmlParser.setInput(inputStream, null); @Nullable TtmlSubtitle ttmlSubtitle = null; ArrayDeque nodeStack = new ArrayDeque<>(); int unsupportedNodeDepth = 0; int eventType = xmlParser.getEventType(); FrameAndTickRate frameAndTickRate = DEFAULT_FRAME_AND_TICK_RATE; CellResolution cellResolution = DEFAULT_CELL_RESOLUTION; @Nullable TtsExtent ttsExtent = null; while (eventType != XmlPullParser.END_DOCUMENT) { @Nullable TtmlNode parent = nodeStack.peek(); if (unsupportedNodeDepth == 0) { String name = xmlParser.getName(); if (eventType == XmlPullParser.START_TAG) { if (TtmlNode.TAG_TT.equals(name)) { frameAndTickRate = parseFrameAndTickRates(xmlParser); cellResolution = parseCellResolution(xmlParser, DEFAULT_CELL_RESOLUTION); ttsExtent = parseTtsExtent(xmlParser); } if (!isSupportedTag(name)) { Log.i(TAG, "Ignoring unsupported tag: " + xmlParser.getName()); unsupportedNodeDepth++; } else if (TtmlNode.TAG_HEAD.equals(name)) { parseHeader(xmlParser, globalStyles, cellResolution, ttsExtent, regionMap, imageMap); } else { try { TtmlNode node = parseNode(xmlParser, parent, regionMap, frameAndTickRate); nodeStack.push(node); if (parent != null) { parent.addChild(node); } } catch (SubtitleDecoderException e) { Log.w(TAG, "Suppressing parser error", e); // Treat the node (and by extension, all of its children) as unsupported. unsupportedNodeDepth++; } } } else if (eventType == XmlPullParser.TEXT) { Assertions.checkNotNull(parent).addChild(TtmlNode.buildTextNode(xmlParser.getText())); } else if (eventType == XmlPullParser.END_TAG) { if (xmlParser.getName().equals(TtmlNode.TAG_TT)) { ttmlSubtitle = new TtmlSubtitle( Assertions.checkNotNull(nodeStack.peek()), globalStyles, regionMap, imageMap); } nodeStack.pop(); } } else { if (eventType == XmlPullParser.START_TAG) { unsupportedNodeDepth++; } else if (eventType == XmlPullParser.END_TAG) { unsupportedNodeDepth--; } } xmlParser.next(); eventType = xmlParser.getEventType(); } if (ttmlSubtitle != null) { return ttmlSubtitle; } else { throw new SubtitleDecoderException("No TTML subtitles found"); } } catch (XmlPullParserException xppe) { throw new SubtitleDecoderException("Unable to decode source", xppe); } catch (IOException e) { throw new IllegalStateException("Unexpected error when reading input.", e); } } private static FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser) throws SubtitleDecoderException { int frameRate = DEFAULT_FRAME_RATE; String frameRateString = xmlParser.getAttributeValue(TTP, "frameRate"); if (frameRateString != null) { frameRate = Integer.parseInt(frameRateString); } float frameRateMultiplier = 1; String frameRateMultiplierString = xmlParser.getAttributeValue(TTP, "frameRateMultiplier"); if (frameRateMultiplierString != null) { String[] parts = Util.split(frameRateMultiplierString, " "); if (parts.length != 2) { throw new SubtitleDecoderException("frameRateMultiplier doesn't have 2 parts"); } float numerator = Integer.parseInt(parts[0]); float denominator = Integer.parseInt(parts[1]); frameRateMultiplier = numerator / denominator; } int subFrameRate = DEFAULT_FRAME_AND_TICK_RATE.subFrameRate; String subFrameRateString = xmlParser.getAttributeValue(TTP, "subFrameRate"); if (subFrameRateString != null) { subFrameRate = Integer.parseInt(subFrameRateString); } int tickRate = DEFAULT_FRAME_AND_TICK_RATE.tickRate; String tickRateString = xmlParser.getAttributeValue(TTP, "tickRate"); if (tickRateString != null) { tickRate = Integer.parseInt(tickRateString); } return new FrameAndTickRate(frameRate * frameRateMultiplier, subFrameRate, tickRate); } private static CellResolution parseCellResolution( XmlPullParser xmlParser, CellResolution defaultValue) throws SubtitleDecoderException { String cellResolution = xmlParser.getAttributeValue(TTP, "cellResolution"); if (cellResolution == null) { return defaultValue; } Matcher cellResolutionMatcher = CELL_RESOLUTION.matcher(cellResolution); if (!cellResolutionMatcher.matches()) { Log.w(TAG, "Ignoring malformed cell resolution: " + cellResolution); return defaultValue; } try { int columns = Integer.parseInt(Assertions.checkNotNull(cellResolutionMatcher.group(1))); int rows = Integer.parseInt(Assertions.checkNotNull(cellResolutionMatcher.group(2))); if (columns == 0 || rows == 0) { throw new SubtitleDecoderException("Invalid cell resolution " + columns + " " + rows); } return new CellResolution(columns, rows); } catch (NumberFormatException e) { Log.w(TAG, "Ignoring malformed cell resolution: " + cellResolution); return defaultValue; } } @Nullable private static TtsExtent parseTtsExtent(XmlPullParser xmlParser) { @Nullable String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); if (ttsExtent == null) { return null; } Matcher extentMatcher = PIXEL_COORDINATES.matcher(ttsExtent); if (!extentMatcher.matches()) { Log.w(TAG, "Ignoring non-pixel tts extent: " + ttsExtent); return null; } try { int width = Integer.parseInt(Assertions.checkNotNull(extentMatcher.group(1))); int height = Integer.parseInt(Assertions.checkNotNull(extentMatcher.group(2))); return new TtsExtent(width, height); } catch (NumberFormatException e) { Log.w(TAG, "Ignoring malformed tts extent: " + ttsExtent); return null; } } private static Map parseHeader( XmlPullParser xmlParser, Map globalStyles, CellResolution cellResolution, @Nullable TtsExtent ttsExtent, Map globalRegions, Map imageMap) throws IOException, XmlPullParserException { do { xmlParser.next(); if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_STYLE)) { @Nullable String parentStyleId = XmlPullParserUtil.getAttributeValue(xmlParser, ATTR_STYLE); TtmlStyle style = parseStyleAttributes(xmlParser, new TtmlStyle()); if (parentStyleId != null) { for (String id : parseStyleIds(parentStyleId)) { style.chain(globalStyles.get(id)); } } String styleId = style.getId(); if (styleId != null) { globalStyles.put(styleId, style); } } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_REGION)) { @Nullable TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution, ttsExtent); if (ttmlRegion != null) { globalRegions.put(ttmlRegion.id, ttmlRegion); } } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_METADATA)) { parseMetadata(xmlParser, imageMap); } } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_HEAD)); return globalStyles; } private static void parseMetadata(XmlPullParser xmlParser, Map imageMap) throws IOException, XmlPullParserException { do { xmlParser.next(); if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_IMAGE)) { @Nullable String id = XmlPullParserUtil.getAttributeValue(xmlParser, "id"); if (id != null) { String encodedBitmapData = xmlParser.nextText(); imageMap.put(id, encodedBitmapData); } } } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_METADATA)); } /** * Parses a region declaration. * *

Supports both percentage and pixel defined regions. In case of pixel defined regions the * passed {@code ttsExtent} is used as a reference window to convert the pixel values to * fractions. In case of missing tts:extent the pixel defined regions can't be parsed, and null is * returned. */ @Nullable private static TtmlRegion parseRegionAttributes( XmlPullParser xmlParser, CellResolution cellResolution, @Nullable TtsExtent ttsExtent) { @Nullable String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID); if (regionId == null) { return null; } float position; float line; @Nullable String regionOrigin = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_ORIGIN); if (regionOrigin != null) { Matcher originPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin); Matcher originPixelMatcher = PIXEL_COORDINATES.matcher(regionOrigin); if (originPercentageMatcher.matches()) { try { position = Float.parseFloat(Assertions.checkNotNull(originPercentageMatcher.group(1))) / 100f; line = Float.parseFloat(Assertions.checkNotNull(originPercentageMatcher.group(2))) / 100f; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); return null; } } else if (originPixelMatcher.matches()) { if (ttsExtent == null) { Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); return null; } try { int width = Integer.parseInt(Assertions.checkNotNull(originPixelMatcher.group(1))); int height = Integer.parseInt(Assertions.checkNotNull(originPixelMatcher.group(2))); // Convert pixel values to fractions. position = width / (float) ttsExtent.width; line = height / (float) ttsExtent.height; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); return null; } } else { Log.w(TAG, "Ignoring region with unsupported origin: " + regionOrigin); return null; } } else { Log.w(TAG, "Ignoring region without an origin"); return null; // TODO: Should default to top left as below in this case, but need to fix // https://github.com/google/ExoPlayer/issues/2953 first. // Origin is omitted. Default to top left. // position = 0; // line = 0; } float width; float height; @Nullable String regionExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); if (regionExtent != null) { Matcher extentPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent); Matcher extentPixelMatcher = PIXEL_COORDINATES.matcher(regionExtent); if (extentPercentageMatcher.matches()) { try { width = Float.parseFloat(Assertions.checkNotNull(extentPercentageMatcher.group(1))) / 100f; height = Float.parseFloat(Assertions.checkNotNull(extentPercentageMatcher.group(2))) / 100f; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); return null; } } else if (extentPixelMatcher.matches()) { if (ttsExtent == null) { Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); return null; } try { int extentWidth = Integer.parseInt(Assertions.checkNotNull(extentPixelMatcher.group(1))); int extentHeight = Integer.parseInt(Assertions.checkNotNull(extentPixelMatcher.group(2))); // Convert pixel values to fractions. width = extentWidth / (float) ttsExtent.width; height = extentHeight / (float) ttsExtent.height; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); return null; } } else { Log.w(TAG, "Ignoring region with unsupported extent: " + regionOrigin); return null; } } else { Log.w(TAG, "Ignoring region without an extent"); return null; // TODO: Should default to extent of parent as below in this case, but need to fix // https://github.com/google/ExoPlayer/issues/2953 first. // Extent is omitted. Default to extent of parent. // width = 1; // height = 1; } @Cue.AnchorType int lineAnchor = Cue.ANCHOR_TYPE_START; @Nullable String displayAlign = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_DISPLAY_ALIGN); if (displayAlign != null) { switch (Ascii.toLowerCase(displayAlign)) { case "center": lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; line += height / 2; break; case "after": lineAnchor = Cue.ANCHOR_TYPE_END; line += height; break; default: // Default "before" case. Do nothing. break; } } float regionTextHeight = 1.0f / cellResolution.rows; @Cue.VerticalType int verticalType = Cue.TYPE_UNSET; @Nullable String writingDirection = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_WRITING_MODE); if (writingDirection != null) { switch (Ascii.toLowerCase(writingDirection)) { // TODO: Support horizontal RTL modes. case TtmlNode.VERTICAL: case TtmlNode.VERTICAL_LR: verticalType = Cue.VERTICAL_TYPE_LR; break; case TtmlNode.VERTICAL_RL: verticalType = Cue.VERTICAL_TYPE_RL; break; default: // ignore break; } } return new TtmlRegion( regionId, position, line, /* lineType= */ Cue.LINE_TYPE_FRACTION, lineAnchor, width, height, /* textSizeType= */ Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING, /* textSize= */ regionTextHeight, verticalType); } private static String[] parseStyleIds(String parentStyleIds) { parentStyleIds = parentStyleIds.trim(); return parentStyleIds.isEmpty() ? new String[0] : Util.split(parentStyleIds, "\\s+"); } private static @PolyNull TtmlStyle parseStyleAttributes( XmlPullParser parser, @PolyNull TtmlStyle style) { int attributeCount = parser.getAttributeCount(); for (int i = 0; i < attributeCount; i++) { String attributeValue = parser.getAttributeValue(i); switch (parser.getAttributeName(i)) { case TtmlNode.ATTR_ID: if (TtmlNode.TAG_STYLE.equals(parser.getName())) { style = createIfNull(style).setId(attributeValue); } break; case TtmlNode.ATTR_TTS_BACKGROUND_COLOR: style = createIfNull(style); try { style.setBackgroundColor(ColorParser.parseTtmlColor(attributeValue)); } catch (IllegalArgumentException e) { Log.w(TAG, "Failed parsing background value: " + attributeValue); } break; case TtmlNode.ATTR_TTS_COLOR: style = createIfNull(style); try { style.setFontColor(ColorParser.parseTtmlColor(attributeValue)); } catch (IllegalArgumentException e) { Log.w(TAG, "Failed parsing color value: " + attributeValue); } break; case TtmlNode.ATTR_TTS_FONT_FAMILY: style = createIfNull(style).setFontFamily(attributeValue); break; case TtmlNode.ATTR_TTS_FONT_SIZE: try { style = createIfNull(style); parseFontSize(attributeValue, style); } catch (SubtitleDecoderException e) { Log.w(TAG, "Failed parsing fontSize value: " + attributeValue); } break; case TtmlNode.ATTR_TTS_FONT_WEIGHT: style = createIfNull(style).setBold(TtmlNode.BOLD.equalsIgnoreCase(attributeValue)); break; case TtmlNode.ATTR_TTS_FONT_STYLE: style = createIfNull(style).setItalic(TtmlNode.ITALIC.equalsIgnoreCase(attributeValue)); break; case TtmlNode.ATTR_TTS_TEXT_ALIGN: style = createIfNull(style).setTextAlign(parseAlignment(attributeValue)); break; case TtmlNode.ATTR_EBUTTS_MULTI_ROW_ALIGN: style = createIfNull(style).setMultiRowAlign(parseAlignment(attributeValue)); break; case TtmlNode.ATTR_TTS_TEXT_COMBINE: switch (Ascii.toLowerCase(attributeValue)) { case TtmlNode.COMBINE_NONE: style = createIfNull(style).setTextCombine(false); break; case TtmlNode.COMBINE_ALL: style = createIfNull(style).setTextCombine(true); break; default: // ignore break; } break; case TtmlNode.ATTR_TTS_RUBY: switch (Ascii.toLowerCase(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 (Ascii.toLowerCase(attributeValue)) { case TtmlNode.ANNOTATION_POSITION_BEFORE: style = createIfNull(style).setRubyPosition(TextAnnotation.POSITION_BEFORE); break; case TtmlNode.ANNOTATION_POSITION_AFTER: style = createIfNull(style).setRubyPosition(TextAnnotation.POSITION_AFTER); break; default: // ignore break; } break; case TtmlNode.ATTR_TTS_TEXT_DECORATION: switch (Ascii.toLowerCase(attributeValue)) { case TtmlNode.LINETHROUGH: style = createIfNull(style).setLinethrough(true); break; case TtmlNode.NO_LINETHROUGH: style = createIfNull(style).setLinethrough(false); break; case TtmlNode.UNDERLINE: style = createIfNull(style).setUnderline(true); break; case TtmlNode.NO_UNDERLINE: style = createIfNull(style).setUnderline(false); break; } break; case TtmlNode.ATTR_TTS_TEXT_EMPHASIS: style = createIfNull(style).setTextEmphasis(TextEmphasis.parse(attributeValue)); break; case TtmlNode.ATTR_TTS_SHEAR: style = createIfNull(style).setShearPercentage(parseShear(attributeValue)); break; default: // ignore break; } } return style; } private static TtmlStyle createIfNull(@Nullable TtmlStyle style) { return style == null ? new TtmlStyle() : style; } @Nullable private static Layout.Alignment parseAlignment(String alignment) { switch (Ascii.toLowerCase(alignment)) { case TtmlNode.LEFT: case TtmlNode.START: return Layout.Alignment.ALIGN_NORMAL; case TtmlNode.RIGHT: case TtmlNode.END: return Layout.Alignment.ALIGN_OPPOSITE; case TtmlNode.CENTER: return Layout.Alignment.ALIGN_CENTER; default: return null; } } private static TtmlNode parseNode( XmlPullParser parser, @Nullable TtmlNode parent, Map regionMap, FrameAndTickRate frameAndTickRate) throws SubtitleDecoderException { long duration = C.TIME_UNSET; long startTime = C.TIME_UNSET; long endTime = C.TIME_UNSET; String regionId = TtmlNode.ANONYMOUS_REGION_ID; @Nullable String imageId = null; @Nullable String[] styleIds = null; int attributeCount = parser.getAttributeCount(); @Nullable TtmlStyle style = parseStyleAttributes(parser, null); for (int i = 0; i < attributeCount; i++) { String attr = parser.getAttributeName(i); String value = parser.getAttributeValue(i); switch (attr) { case ATTR_BEGIN: startTime = parseTimeExpression(value, frameAndTickRate); break; case ATTR_END: endTime = parseTimeExpression(value, frameAndTickRate); break; case ATTR_DURATION: duration = parseTimeExpression(value, frameAndTickRate); break; case ATTR_STYLE: // IDREFS: potentially multiple space delimited ids String[] ids = parseStyleIds(value); if (ids.length > 0) { styleIds = ids; } break; case ATTR_REGION: if (regionMap.containsKey(value)) { // If the region has not been correctly declared or does not define a position, we use // the anonymous region. regionId = value; } break; case ATTR_IMAGE: // Parse URI reference only if refers to an element in the same document (it must start // with '#'). Resolving URIs from external sources is not supported. if (value.startsWith("#")) { imageId = value.substring(1); } break; default: // Do nothing. break; } } if (parent != null && parent.startTimeUs != C.TIME_UNSET) { if (startTime != C.TIME_UNSET) { startTime += parent.startTimeUs; } if (endTime != C.TIME_UNSET) { endTime += parent.startTimeUs; } } if (endTime == C.TIME_UNSET) { if (duration != C.TIME_UNSET) { // Infer the end time from the duration. endTime = startTime + duration; } else if (parent != null && parent.endTimeUs != C.TIME_UNSET) { // If the end time remains unspecified, then it should be inherited from the parent. endTime = parent.endTimeUs; } } return TtmlNode.buildNode( parser.getName(), startTime, endTime, style, styleIds, regionId, imageId, parent); } private static boolean isSupportedTag(String tag) { return tag.equals(TtmlNode.TAG_TT) || tag.equals(TtmlNode.TAG_HEAD) || tag.equals(TtmlNode.TAG_BODY) || tag.equals(TtmlNode.TAG_DIV) || tag.equals(TtmlNode.TAG_P) || tag.equals(TtmlNode.TAG_SPAN) || tag.equals(TtmlNode.TAG_BR) || tag.equals(TtmlNode.TAG_STYLE) || tag.equals(TtmlNode.TAG_STYLING) || tag.equals(TtmlNode.TAG_LAYOUT) || tag.equals(TtmlNode.TAG_REGION) || tag.equals(TtmlNode.TAG_METADATA) || tag.equals(TtmlNode.TAG_IMAGE) || tag.equals(TtmlNode.TAG_DATA) || tag.equals(TtmlNode.TAG_INFORMATION); } private static void parseFontSize(String expression, TtmlStyle out) throws SubtitleDecoderException { String[] expressions = Util.split(expression, "\\s+"); Matcher matcher; if (expressions.length == 1) { matcher = FONT_SIZE.matcher(expression); } else if (expressions.length == 2) { matcher = FONT_SIZE.matcher(expressions[1]); Log.w( TAG, "Multiple values in fontSize attribute. Picking the second value for vertical font" + " size and ignoring the first."); } else { throw new SubtitleDecoderException( "Invalid number of entries for fontSize: " + expressions.length + "."); } if (matcher.matches()) { String unit = Assertions.checkNotNull(matcher.group(3)); switch (unit) { case "px": out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_PIXEL); break; case "em": out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_EM); break; case "%": out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_PERCENT); break; default: throw new SubtitleDecoderException("Invalid unit for fontSize: '" + unit + "'."); } out.setFontSize(Float.parseFloat(Assertions.checkNotNull(matcher.group(1)))); } else { throw new SubtitleDecoderException("Invalid expression for fontSize: '" + expression + "'."); } } /** * Returns the parsed shear percentage (between -100.0 and +100.0 inclusive), or {@link * TtmlStyle#UNSPECIFIED_SHEAR} if parsing failed. */ private static float parseShear(String expression) { Matcher matcher = SIGNED_PERCENTAGE.matcher(expression); if (!matcher.matches()) { Log.w(TAG, "Invalid value for shear: " + expression); return TtmlStyle.UNSPECIFIED_SHEAR; } try { String percentage = Assertions.checkNotNull(matcher.group(1)); float value = Float.parseFloat(percentage); // https://www.w3.org/TR/2018/REC-ttml2-20181108/#semantics-style-procedures-shear // If the absolute value of the specified percentage is greater than 100%, then it must be // interpreted as if 100% were specified with the appropriate sign. value = max(-100f, value); value = min(100f, value); return value; } catch (NumberFormatException e) { Log.w(TAG, "Failed to parse shear: " + expression, e); return TtmlStyle.UNSPECIFIED_SHEAR; } } /** * Parses a time expression, returning the parsed timestamp. * *

For the format of a time expression, see: timeExpression * * @param time A string that includes the time expression. * @param frameAndTickRate The effective frame and tick rates of the stream. * @return The parsed timestamp in microseconds. * @throws SubtitleDecoderException If the given string does not contain a valid time expression. */ private static long parseTimeExpression(String time, FrameAndTickRate frameAndTickRate) throws SubtitleDecoderException { Matcher matcher = CLOCK_TIME.matcher(time); if (matcher.matches()) { String hours = Assertions.checkNotNull(matcher.group(1)); double durationSeconds = Long.parseLong(hours) * 3600; String minutes = Assertions.checkNotNull(matcher.group(2)); durationSeconds += Long.parseLong(minutes) * 60; String seconds = Assertions.checkNotNull(matcher.group(3)); durationSeconds += Long.parseLong(seconds); @Nullable String fraction = matcher.group(4); durationSeconds += (fraction != null) ? Double.parseDouble(fraction) : 0; @Nullable String frames = matcher.group(5); durationSeconds += (frames != null) ? Long.parseLong(frames) / frameAndTickRate.effectiveFrameRate : 0; @Nullable String subframes = matcher.group(6); durationSeconds += (subframes != null) ? ((double) Long.parseLong(subframes)) / frameAndTickRate.subFrameRate / frameAndTickRate.effectiveFrameRate : 0; return (long) (durationSeconds * C.MICROS_PER_SECOND); } matcher = OFFSET_TIME.matcher(time); if (matcher.matches()) { String timeValue = Assertions.checkNotNull(matcher.group(1)); double offsetSeconds = Double.parseDouble(timeValue); String unit = Assertions.checkNotNull(matcher.group(2)); switch (unit) { case "h": offsetSeconds *= 3600; break; case "m": offsetSeconds *= 60; break; case "s": // Do nothing. break; case "ms": offsetSeconds /= 1000; break; case "f": offsetSeconds /= frameAndTickRate.effectiveFrameRate; break; case "t": offsetSeconds /= frameAndTickRate.tickRate; break; } return (long) (offsetSeconds * C.MICROS_PER_SECOND); } throw new SubtitleDecoderException("Malformed time expression: " + time); } private static final class FrameAndTickRate { final float effectiveFrameRate; final int subFrameRate; final int tickRate; FrameAndTickRate(float effectiveFrameRate, int subFrameRate, int tickRate) { this.effectiveFrameRate = effectiveFrameRate; this.subFrameRate = subFrameRate; this.tickRate = tickRate; } } /** Represents the cell resolution for a TTML file. */ private static final class CellResolution { final int columns; final int rows; CellResolution(int columns, int rows) { this.columns = columns; this.rows = rows; } } /** Represents the tts:extent for a TTML file. */ private static final class TtsExtent { final int width; final int height; TtsExtent(int width, int height) { this.width = width; this.height = height; } } }