| /* |
| * 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.text.Layout; |
| import androidx.annotation.Nullable; |
| import com.google.android.exoplayer2.C; |
| import com.google.android.exoplayer2.text.Cue; |
| import com.google.android.exoplayer2.text.SimpleSubtitleDecoder; |
| import com.google.android.exoplayer2.text.Subtitle; |
| 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.ColorParser; |
| import com.google.android.exoplayer2.util.Log; |
| import com.google.android.exoplayer2.util.Util; |
| import com.google.android.exoplayer2.util.XmlPullParserUtil; |
| 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: |
| * |
| * <ul> |
| * <li>content |
| * <li>core |
| * <li>presentation |
| * <li>profile |
| * <li>structure |
| * <li>time-offset |
| * <li>timing |
| * <li>tickRate |
| * <li>time-clock-with-frames |
| * <li>time-clock |
| * <li>time-offset-with-frames |
| * <li>time-offset-with-ticks |
| * <li>cell-resolution |
| * </ul> |
| * |
| * @see <a href="http://www.w3.org/TR/ttaf1-dfxp/">TTML specification</a> |
| */ |
| 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|%)$"); |
| private 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<String, TtmlStyle> globalStyles = new HashMap<>(); |
| Map<String, TtmlRegion> regionMap = new HashMap<>(); |
| Map<String, String> 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<TtmlNode> 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 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 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 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 Map<String, TtmlStyle> parseHeader( |
| XmlPullParser xmlParser, |
| Map<String, TtmlStyle> globalStyles, |
| CellResolution cellResolution, |
| @Nullable TtsExtent ttsExtent, |
| Map<String, TtmlRegion> globalRegions, |
| Map<String, String> 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 void parseMetadata(XmlPullParser xmlParser, Map<String, String> 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. |
| * |
| * <p>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 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 (Util.toLowerInvariant(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; |
| return new TtmlRegion( |
| regionId, |
| position, |
| line, |
| /* lineType= */ Cue.LINE_TYPE_FRACTION, |
| lineAnchor, |
| width, |
| height, |
| /* textSizeType= */ Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING, |
| /* textSize= */ regionTextHeight); |
| } |
| |
| private String[] parseStyleIds(String parentStyleIds) { |
| parentStyleIds = parentStyleIds.trim(); |
| return parentStyleIds.isEmpty() ? new String[0] : Util.split(parentStyleIds, "\\s+"); |
| } |
| |
| @PolyNull |
| private 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: |
| switch (Util.toLowerInvariant(attributeValue)) { |
| case TtmlNode.LEFT: |
| case TtmlNode.START: |
| style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_NORMAL); |
| break; |
| case TtmlNode.RIGHT: |
| case TtmlNode.END: |
| style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_OPPOSITE); |
| break; |
| case TtmlNode.CENTER: |
| style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_CENTER); |
| break; |
| default: |
| // ignore |
| break; |
| } |
| break; |
| case TtmlNode.ATTR_TTS_TEXT_COMBINE: |
| switch (Util.toLowerInvariant(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 (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: |
| switch (Util.toLowerInvariant(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_WRITING_MODE: |
| switch (Util.toLowerInvariant(attributeValue)) { |
| // TODO: Support horizontal RTL modes. |
| case TtmlNode.VERTICAL: |
| case TtmlNode.VERTICAL_LR: |
| style = createIfNull(style).setVerticalType(Cue.VERTICAL_TYPE_LR); |
| break; |
| case TtmlNode.VERTICAL_RL: |
| style = createIfNull(style).setVerticalType(Cue.VERTICAL_TYPE_RL); |
| break; |
| default: |
| // ignore |
| break; |
| } |
| break; |
| default: |
| // ignore |
| break; |
| } |
| } |
| return style; |
| } |
| |
| private TtmlStyle createIfNull(@Nullable TtmlStyle style) { |
| return style == null ? new TtmlStyle() : style; |
| } |
| |
| private TtmlNode parseNode( |
| XmlPullParser parser, |
| @Nullable TtmlNode parent, |
| Map<String, TtmlRegion> 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 + "'."); |
| } |
| } |
| |
| /** |
| * Parses a time expression, returning the parsed timestamp. |
| * <p> |
| * For the format of a time expression, see: |
| * <a href="http://www.w3.org/TR/ttaf1-dfxp/#timing-value-timeExpression">timeExpression</a> |
| * |
| * @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; |
| } |
| } |
| } |