diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 89f209633e..6e84849c7e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -71,6 +71,7 @@ [#8456](https://github.com/google/ExoPlayer/issues/8456)). * Fix CEA-708 priority handling to sort cues in the order defined by the spec ([#8704](https://github.com/google/ExoPlayer/issues/8704)). + * Support TTML `textEmphasis` attributes, used for Japanese boutens. * MediaSession extension: Remove dependency to core module and rely on common only. The `TimelineQueueEditor` uses a new `MediaDescriptionConverter` for this purpose and does not rely on the `ConcatenatingMediaSource` anymore. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java index 8ed84d6f6b..b7fb4c2d61 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/RubySpan.java @@ -16,12 +16,6 @@ */ package com.google.android.exoplayer2.text.span; -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import androidx.annotation.IntDef; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; - /** * A styling span for ruby text. * @@ -38,48 +32,13 @@ import java.lang.annotation.Retention; // rubies (e.g. HTML tag). public final class RubySpan { - /** The ruby position is unknown. */ - public static final int POSITION_UNKNOWN = -1; - - /** - * The ruby text should be positioned above the base text. - * - *

For vertical text it should be positioned to the right, same as CSS's ruby-position. - */ - public static final int POSITION_OVER = 1; - - /** - * The ruby text should be positioned below the base text. - * - *

For vertical text it should be positioned to the left, same as CSS's ruby-position. - */ - public static final int POSITION_UNDER = 2; - - /** - * The possible positions of the ruby text relative to the base text. - * - *

One of: - * - *

- */ - @Documented - @Retention(SOURCE) - @IntDef({POSITION_UNKNOWN, POSITION_OVER, POSITION_UNDER}) - public @interface Position {} - /** The ruby text, i.e. the smaller explanatory characters. */ public final String rubyText; /** The position of the ruby text relative to the base text. */ - @Position public final int position; + @TextAnnotation.Position public final int position; - public RubySpan(String rubyText, @Position int position) { + public RubySpan(String rubyText, @TextAnnotation.Position int position) { this.rubyText = rubyText; this.position = position; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextAnnotation.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextAnnotation.java new file mode 100644 index 0000000000..6d86aac161 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextAnnotation.java @@ -0,0 +1,62 @@ +/* + * Copyright 2021 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.span; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +/** Properties of a text annotation (i.e. ruby, text emphasis marks). */ +public final class TextAnnotation { + /** The text annotation position is unknown. */ + public static final int POSITION_UNKNOWN = -1; + + /** + * For horizontal text, the text annotation should be positioned above the base text. + * + *

For vertical text it should be positioned to the right, same as CSS's ruby-position. + */ + public static final int POSITION_BEFORE = 1; + + /** + * For horizontal text, the text annotation should be positioned below the base text. + * + *

For vertical text it should be positioned to the left, same as CSS's ruby-position. + */ + public static final int POSITION_AFTER = 2; + + /** + * The possible positions of the annotation text relative to the base text. + * + *

One of: + * + *

+ */ + @Documented + @Retention(SOURCE) + @IntDef({POSITION_UNKNOWN, POSITION_BEFORE, POSITION_AFTER}) + public @interface Position {} + + private TextAnnotation() {} +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java new file mode 100644 index 0000000000..87f37ec2d0 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/span/TextEmphasisSpan.java @@ -0,0 +1,94 @@ +/* + * Copyright 2021 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.span; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +/** + * A styling span for text emphasis marks. + * + *

These are pronunciation aids such as Japanese boutens which can be + * rendered using the + * text-emphasis CSS property. + */ +// NOTE: There's no Android layout support for text emphasis, so this span currently doesn't extend +// any styling superclasses (e.g. MetricAffectingSpan). The only way to render this emphasis is to +// extract the spans and do the layout manually. +public final class TextEmphasisSpan { + + /** + * The possible mark shapes that can be used. + * + *

One of: + * + *

+ */ + @Documented + @Retention(SOURCE) + @IntDef({MARK_SHAPE_NONE, MARK_SHAPE_CIRCLE, MARK_SHAPE_DOT, MARK_SHAPE_SESAME}) + public @interface MarkShape {} + + public static final int MARK_SHAPE_NONE = 0; + public static final int MARK_SHAPE_CIRCLE = 1; + public static final int MARK_SHAPE_DOT = 2; + public static final int MARK_SHAPE_SESAME = 3; + + /** + * The possible mark fills that can be used. + * + *

One of: + * + *

+ */ + @Documented + @Retention(SOURCE) + @IntDef({MARK_FILL_UNKNOWN, MARK_FILL_FILLED, MARK_FILL_OPEN}) + public @interface MarkFill {} + + public static final int MARK_FILL_UNKNOWN = 0; + public static final int MARK_FILL_FILLED = 1; + public static final int MARK_FILL_OPEN = 2; + + /** The mark shape used for text emphasis. */ + @MarkShape public int markShape; + + /** The mark fill for the text emphasis mark. */ + @MarkShape public int markFill; + + /** The position of the text emphasis relative to the base text. */ + @TextAnnotation.Position public final int position; + + public TextEmphasisSpan( + @MarkShape int shape, @MarkFill int fill, @TextAnnotation.Position int position) { + this.markShape = shape; + this.markFill = fill; + this.position = position; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java new file mode 100644 index 0000000000..a52d818c6e --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TextEmphasis.java @@ -0,0 +1,217 @@ +/* + * Copyright 2021 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 static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.text.TextUtils; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import com.google.common.collect.Sets; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Represents a + * tts:textEmphasis attribute. + */ +/* package */ final class TextEmphasis { + + @Documented + @Retention(SOURCE) + @IntDef({ + TextEmphasisSpan.MARK_SHAPE_NONE, + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_SHAPE_DOT, + TextEmphasisSpan.MARK_SHAPE_SESAME, + MARK_SHAPE_AUTO + }) + @interface MarkShape {} + + /** + * The "auto" mark shape is only defined in TTML and is resolved to a concrete shape when building + * the {@link Cue}. Hence, it is not defined in {@link TextEmphasisSpan.MarkShape}. + */ + public static final int MARK_SHAPE_AUTO = -1; + + @Documented + @Retention(SOURCE) + @IntDef({ + TextAnnotation.POSITION_UNKNOWN, + TextAnnotation.POSITION_BEFORE, + TextAnnotation.POSITION_AFTER, + POSITION_OUTSIDE + }) + public @interface Position {} + + /** + * The "outside" position is only defined in TTML and is resolved before outputting a {@link Cue} + * object. Hence, it is not defined in {@link TextAnnotation.Position}. + */ + public static final int POSITION_OUTSIDE = -2; + + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + + private static final ImmutableSet SINGLE_STYLE_VALUES = + ImmutableSet.of(TtmlNode.TEXT_EMPHASIS_AUTO, TtmlNode.TEXT_EMPHASIS_NONE); + + private static final ImmutableSet MARK_SHAPE_VALUES = + ImmutableSet.of( + TtmlNode.TEXT_EMPHASIS_MARK_DOT, + TtmlNode.TEXT_EMPHASIS_MARK_SESAME, + TtmlNode.TEXT_EMPHASIS_MARK_CIRCLE); + + private static final ImmutableSet MARK_FILL_VALUES = + ImmutableSet.of(TtmlNode.TEXT_EMPHASIS_MARK_FILLED, TtmlNode.TEXT_EMPHASIS_MARK_OPEN); + + private static final ImmutableSet POSITION_VALUES = + ImmutableSet.of( + TtmlNode.ANNOTATION_POSITION_AFTER, + TtmlNode.ANNOTATION_POSITION_BEFORE, + TtmlNode.ANNOTATION_POSITION_OUTSIDE); + + /** The text emphasis mark shape. */ + @MarkShape public final int markShape; + + /** The fill style of the text emphasis mark. */ + @TextEmphasisSpan.MarkFill public final int markFill; + + /** The position of the text emphasis relative to the base text. */ + @Position public final int position; + + private TextEmphasis( + @MarkShape int markShape, + @TextEmphasisSpan.MarkFill int markFill, + @TextAnnotation.Position int position) { + this.markShape = markShape; + this.markFill = markFill; + this.position = position; + } + + /** + * Parses a TTML + * tts:textEmphasis attribute. Returns null if parsing fails. + * + *

The parser searches for {@code emphasis-style} and {@code emphasis-position} independently. + * If a valid style is not found, the default style is used. If a valid position is not found, the + * default position is used. + * + *

Not implemented: + * + *

    + *
  • {@code emphasis-color} + *
  • Quoted string {@code emphasis-style} + *
+ */ + @Nullable + public static TextEmphasis parse(@Nullable String value) { + if (value == null) { + return null; + } + + String parsingValue = value.trim(); + if (parsingValue.isEmpty()) { + return null; + } + + return parseWords(ImmutableSet.copyOf(TextUtils.split(parsingValue, WHITESPACE_PATTERN))); + } + + private static TextEmphasis parseWords(ImmutableSet nodes) { + Set matchingPositions = Sets.intersection(POSITION_VALUES, nodes); + // If no emphasis position is specified, then the emphasis position must be interpreted as if + // a position of outside were specified: + // https://www.w3.org/TR/2018/REC-ttml2-20181108/#style-attribute-textEmphasis + @Position int position; + switch (Iterables.getFirst(matchingPositions, TtmlNode.ANNOTATION_POSITION_OUTSIDE)) { + case TtmlNode.ANNOTATION_POSITION_AFTER: + position = TextAnnotation.POSITION_AFTER; + break; + case TtmlNode.ANNOTATION_POSITION_OUTSIDE: + position = POSITION_OUTSIDE; + break; + case TtmlNode.ANNOTATION_POSITION_BEFORE: + default: + // If an implementation does not recognize or otherwise distinguish an annotation position + // value, then it must be interpreted as if a position of 'before' were specified: + // https://www.w3.org/TR/2018/REC-ttml2-20181108/#style-attribute-textEmphasis + position = TextAnnotation.POSITION_BEFORE; + } + + Set matchingSingleStyles = Sets.intersection(SINGLE_STYLE_VALUES, nodes); + if (!matchingSingleStyles.isEmpty()) { + // If "none" or "auto" are found in the description, ignore the other style (fill, shape) + // attributes. + @MarkShape int markShape; + switch (matchingSingleStyles.iterator().next()) { + case TtmlNode.TEXT_EMPHASIS_NONE: + markShape = TextEmphasisSpan.MARK_SHAPE_NONE; + break; + case TtmlNode.TEXT_EMPHASIS_AUTO: + default: + markShape = MARK_SHAPE_AUTO; + } + // markFill is ignored when markShape is NONE or AUTO + return new TextEmphasis(markShape, TextEmphasisSpan.MARK_FILL_UNKNOWN, position); + } + + Set matchingFills = Sets.intersection(MARK_FILL_VALUES, nodes); + Set matchingShapes = Sets.intersection(MARK_SHAPE_VALUES, nodes); + if (matchingFills.isEmpty() && matchingShapes.isEmpty()) { + // If an implementation does not recognize or otherwise distinguish an emphasis style value, + // then it must be interpreted as if a style of auto were specified; as such, an + // implementation that supports text emphasis marks must minimally support the auto value. + // https://www.w3.org/TR/ttml2/#style-value-emphasis-style. + // + // markFill is ignored when markShape is NONE or AUTO. + return new TextEmphasis(MARK_SHAPE_AUTO, TextEmphasisSpan.MARK_FILL_UNKNOWN, position); + } + + @TextEmphasisSpan.MarkFill int markFill; + switch (Iterables.getFirst(matchingFills, TtmlNode.TEXT_EMPHASIS_MARK_FILLED)) { + case TtmlNode.TEXT_EMPHASIS_MARK_OPEN: + markFill = TextEmphasisSpan.MARK_FILL_OPEN; + break; + case TtmlNode.TEXT_EMPHASIS_MARK_FILLED: + default: + markFill = TextEmphasisSpan.MARK_FILL_FILLED; + } + + @MarkShape int markShape; + switch (Iterables.getFirst(matchingShapes, TtmlNode.TEXT_EMPHASIS_MARK_CIRCLE)) { + case TtmlNode.TEXT_EMPHASIS_MARK_DOT: + markShape = TextEmphasisSpan.MARK_SHAPE_DOT; + break; + case TtmlNode.TEXT_EMPHASIS_MARK_SESAME: + markShape = TextEmphasisSpan.MARK_SHAPE_SESAME; + break; + case TtmlNode.TEXT_EMPHASIS_MARK_CIRCLE: + default: + markShape = TextEmphasisSpan.MARK_SHAPE_CIRCLE; + } + + return new TextEmphasis(markShape, markFill, position); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java index 611eb7ff2f..d024ba22b6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java @@ -22,7 +22,7 @@ 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.text.span.TextAnnotation; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.android.exoplayer2.util.Log; @@ -582,11 +582,11 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { break; case TtmlNode.ATTR_TTS_RUBY_POSITION: switch (Util.toLowerInvariant(attributeValue)) { - case TtmlNode.RUBY_BEFORE: - style = createIfNull(style).setRubyPosition(RubySpan.POSITION_OVER); + case TtmlNode.ANNOTATION_POSITION_BEFORE: + style = createIfNull(style).setRubyPosition(TextAnnotation.POSITION_BEFORE); break; - case TtmlNode.RUBY_AFTER: - style = createIfNull(style).setRubyPosition(RubySpan.POSITION_UNDER); + case TtmlNode.ANNOTATION_POSITION_AFTER: + style = createIfNull(style).setRubyPosition(TextAnnotation.POSITION_AFTER); break; default: // ignore @@ -609,6 +609,11 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { break; } break; + case TtmlNode.ATTR_TTS_TEXT_EMPHASIS: + style = + createIfNull(style) + .setTextEmphasis(TextEmphasis.parse(Util.toLowerInvariant(attributeValue))); + break; default: // ignore break; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java index 8e516dedf1..6dce77b985 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -69,6 +69,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; 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_TEXT_EMPHASIS = "textEmphasis"; public static final String ATTR_TTS_WRITING_MODE = "writingMode"; // Values for ruby @@ -79,9 +80,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; 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 text annotation (i.e. ruby, text emphasis) position + public static final String ANNOTATION_POSITION_BEFORE = "before"; + public static final String ANNOTATION_POSITION_AFTER = "after"; + public static final String ANNOTATION_POSITION_OUTSIDE = "outside"; + // Values for textDecoration public static final String LINETHROUGH = "linethrough"; public static final String NO_LINETHROUGH = "nolinethrough"; @@ -106,6 +109,15 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public static final String VERTICAL_LR = "tblr"; public static final String VERTICAL_RL = "tbrl"; + // Values for textEmphasis + public static final String TEXT_EMPHASIS_NONE = "none"; + public static final String TEXT_EMPHASIS_AUTO = "auto"; + public static final String TEXT_EMPHASIS_MARK_DOT = "dot"; + public static final String TEXT_EMPHASIS_MARK_SESAME = "sesame"; + public static final String TEXT_EMPHASIS_MARK_CIRCLE = "circle"; + public static final String TEXT_EMPHASIS_MARK_FILLED = "filled"; + public static final String TEXT_EMPHASIS_MARK_OPEN = "open"; + @Nullable public final String tag; @Nullable public final String text; public final boolean isTextNode; @@ -243,7 +255,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; TreeMap regionTextOutputs = new TreeMap<>(); traverseForText(timeUs, false, regionId, regionTextOutputs); - traverseForStyle(timeUs, globalStyles, regionTextOutputs); + traverseForStyle(timeUs, globalStyles, regionMap, regionId, regionTextOutputs); List cues = new ArrayList<>(); @@ -354,26 +366,39 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } private void traverseForStyle( - long timeUs, Map globalStyles, Map regionOutputs) { + long timeUs, + Map globalStyles, + Map regionMaps, + String inheritedRegion, + Map regionOutputs) { if (!isActive(timeUs)) { return; } + String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; + for (Map.Entry 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); + @Cue.VerticalType + int verticalType = Assertions.checkNotNull(regionMaps.get(resolvedRegionId)).verticalType; + applyStyleToOutput(globalStyles, regionOutput, start, end, verticalType); } } for (int i = 0; i < getChildCount(); ++i) { - getChild(i).traverseForStyle(timeUs, globalStyles, regionOutputs); + getChild(i) + .traverseForStyle(timeUs, globalStyles, regionMaps, resolvedRegionId, regionOutputs); } } private void applyStyleToOutput( - Map globalStyles, Cue.Builder regionOutput, int start, int end) { + Map globalStyles, + Cue.Builder regionOutput, + int start, + int end, + @Cue.VerticalType int verticalType) { @Nullable TtmlStyle resolvedStyle = TtmlRenderUtil.resolveStyle(style, styleIds, globalStyles); @Nullable SpannableStringBuilder text = (SpannableStringBuilder) regionOutput.getText(); if (text == null) { @@ -381,7 +406,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutput.setText(text); } if (resolvedStyle != null) { - TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent, globalStyles); + TtmlRenderUtil.applyStylesToSpan( + text, start, end, resolvedStyle, parent, globalStyles, verticalType); regionOutput.setTextAlignment(resolvedStyle.getTextAlign()); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java index 13f3fe2b16..e578a04072 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.text.ttml; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; @@ -27,9 +29,12 @@ import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.Cue; 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.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.util.ArrayDeque; @@ -83,7 +88,8 @@ import java.util.Map; int end, TtmlStyle style, @Nullable TtmlNode parent, - Map globalStyles) { + Map globalStyles, + @Cue.VerticalType int verticalType) { if (style.getStyle() != TtmlStyle.UNSPECIFIED) { builder.setSpan(new StyleSpan(style.getStyle()), start, end, @@ -119,6 +125,40 @@ import java.util.Map; end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } + if (style.getTextEmphasis() != null) { + TextEmphasis textEmphasis = checkNotNull(style.getTextEmphasis()); + @TextEmphasisSpan.MarkShape int markShape; + @TextEmphasisSpan.MarkFill int markFill; + if (textEmphasis.markShape == TextEmphasis.MARK_SHAPE_AUTO) { + // If a vertical writing mode applies, then 'auto' is equivalent to 'filled sesame'; + // otherwise, it's equivalent to 'filled circle': + // https://www.w3.org/TR/ttml2/#style-value-emphasis-style + markShape = + (verticalType == Cue.VERTICAL_TYPE_LR || verticalType == Cue.VERTICAL_TYPE_RL) + ? TextEmphasisSpan.MARK_SHAPE_SESAME + : TextEmphasisSpan.MARK_SHAPE_CIRCLE; + markFill = TextEmphasisSpan.MARK_FILL_FILLED; + } else { + markShape = textEmphasis.markShape; + markFill = textEmphasis.markFill; + } + + @TextEmphasis.Position int position; + if (textEmphasis.position == TextEmphasis.POSITION_OUTSIDE) { + // 'outside' is not supported by TextEmphasisSpan, so treat it as 'before': + // https://www.w3.org/TR/ttml2/#style-value-annotation-position + position = TextAnnotation.POSITION_BEFORE; + } else { + position = textEmphasis.position; + } + + SpanUtil.addOrReplaceSpan( + builder, + new TextEmphasisSpan(markShape, markFill, position), + start, + end, + 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. @@ -141,11 +181,11 @@ import java.util.Map; } // TODO: Get rubyPosition from `textNode` when TTML inheritance is implemented. - @RubySpan.Position + @TextAnnotation.Position int rubyPosition = containerNode.style != null ? containerNode.style.getRubyPosition() - : RubySpan.POSITION_UNKNOWN; + : TextAnnotation.POSITION_UNKNOWN; builder.setSpan( new RubySpan(rubyText, rubyPosition), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java index 3ca519660d..4f73601e99 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java @@ -19,7 +19,7 @@ import android.graphics.Typeface; import android.text.Layout; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -83,9 +83,10 @@ import java.lang.annotation.RetentionPolicy; private float fontSize; @Nullable private String id; @RubyType private int rubyType; - @RubySpan.Position private int rubyPosition; + @TextAnnotation.Position private int rubyPosition; @Nullable private Layout.Alignment textAlign; @OptionalBoolean private int textCombine; + @Nullable private TextEmphasis textEmphasis; public TtmlStyle() { linethrough = UNSPECIFIED; @@ -94,7 +95,7 @@ import java.lang.annotation.RetentionPolicy; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; rubyType = UNSPECIFIED; - rubyPosition = RubySpan.POSITION_UNKNOWN; + rubyPosition = TextAnnotation.POSITION_UNKNOWN; textCombine = UNSPECIFIED; } @@ -225,7 +226,7 @@ import java.lang.annotation.RetentionPolicy; if (underline == UNSPECIFIED) { underline = ancestor.underline; } - if (rubyPosition == RubySpan.POSITION_UNKNOWN) { + if (rubyPosition == TextAnnotation.POSITION_UNKNOWN) { rubyPosition = ancestor.rubyPosition; } if (textAlign == null && ancestor.textAlign != null) { @@ -238,6 +239,9 @@ import java.lang.annotation.RetentionPolicy; fontSizeUnit = ancestor.fontSizeUnit; fontSize = ancestor.fontSize; } + if (textEmphasis == null) { + textEmphasis = ancestor.textEmphasis; + } // attributes not inherited as of http://www.w3.org/TR/ttml1/ if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) { setBackgroundColor(ancestor.backgroundColor); @@ -269,12 +273,12 @@ import java.lang.annotation.RetentionPolicy; return rubyType; } - public TtmlStyle setRubyPosition(@RubySpan.Position int position) { + public TtmlStyle setRubyPosition(@TextAnnotation.Position int position) { this.rubyPosition = position; return this; } - @RubySpan.Position + @TextAnnotation.Position public int getRubyPosition() { return rubyPosition; } @@ -299,6 +303,16 @@ import java.lang.annotation.RetentionPolicy; return this; } + @Nullable + public TextEmphasis getTextEmphasis() { + return textEmphasis; + } + + public TtmlStyle setTextEmphasis(@Nullable TextEmphasis textEmphasis) { + this.textEmphasis = textEmphasis; + return this; + } + public TtmlStyle setFontSize(float fontSize) { this.fontSize = fontSize; return this; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java index 40fb1fcbb2..0bd1312942 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java @@ -17,7 +17,7 @@ package com.google.android.exoplayer2.text.webvtt; import android.text.TextUtils; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -195,9 +195,9 @@ import java.util.regex.Pattern; style.setBackgroundColor(ColorParser.parseCssColor(value)); } else if (PROPERTY_RUBY_POSITION.equals(property)) { if (VALUE_OVER.equals(value)) { - style.setRubyPosition(RubySpan.POSITION_OVER); + style.setRubyPosition(TextAnnotation.POSITION_BEFORE); } else if (VALUE_UNDER.equals(value)) { - style.setRubyPosition(RubySpan.POSITION_UNDER); + style.setRubyPosition(TextAnnotation.POSITION_AFTER); } } else if (PROPERTY_TEXT_COMBINE_UPRIGHT.equals(property)) { style.setCombineUpright(VALUE_ALL.equals(value) || value.startsWith(VALUE_DIGITS)); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java index eeb3392e54..45c66302c8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java @@ -20,7 +20,7 @@ import android.text.TextUtils; import androidx.annotation.ColorInt; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -95,7 +95,7 @@ public final class WebvttCssStyle { @OptionalBoolean private int italic; @FontSizeUnit private int fontSizeUnit; private float fontSize; - @RubySpan.Position private int rubyPosition; + @TextAnnotation.Position private int rubyPosition; private boolean combineUpright; public WebvttCssStyle() { @@ -111,7 +111,7 @@ public final class WebvttCssStyle { bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; - rubyPosition = RubySpan.POSITION_UNKNOWN; + rubyPosition = TextAnnotation.POSITION_UNKNOWN; combineUpright = false; } @@ -272,12 +272,12 @@ public final class WebvttCssStyle { return fontSize; } - public WebvttCssStyle setRubyPosition(@RubySpan.Position int rubyPosition) { + public WebvttCssStyle setRubyPosition(@TextAnnotation.Position int rubyPosition) { this.rubyPosition = rubyPosition; return this; } - @RubySpan.Position + @TextAnnotation.Position public int getRubyPosition() { return rubyPosition; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index ed95f6b4e0..a040a3acf3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -39,6 +39,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -572,7 +573,7 @@ public final class WebvttCueParser { StartTag startTag, List nestedElements, List styles) { - @RubySpan.Position int rubyTagPosition = getRubyPosition(styles, cueId, startTag); + @TextAnnotation.Position int rubyTagPosition = getRubyPosition(styles, cueId, startTag); List sortedNestedElements = new ArrayList<>(nestedElements.size()); sortedNestedElements.addAll(nestedElements); Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC); @@ -585,12 +586,12 @@ public final class WebvttCueParser { Element rubyTextElement = sortedNestedElements.get(i); // Use the element's ruby-position if set, otherwise the element's and otherwise // default to OVER. - @RubySpan.Position + @TextAnnotation.Position int rubyPosition = firstKnownRubyPosition( getRubyPosition(styles, cueId, rubyTextElement.startTag), rubyTagPosition, - RubySpan.POSITION_OVER); + TextAnnotation.POSITION_BEFORE); // Move the rubyText from spannedText into the RubySpan. int adjustedRubyTextStart = rubyTextElement.startTag.position - deletedCharCount; int adjustedRubyTextEnd = rubyTextElement.endPosition - deletedCharCount; @@ -607,31 +608,31 @@ public final class WebvttCueParser { } } - @RubySpan.Position + @TextAnnotation.Position private static int getRubyPosition( List styles, @Nullable String cueId, StartTag startTag) { List styleMatches = getApplicableStyles(styles, cueId, startTag); for (int i = 0; i < styleMatches.size(); i++) { WebvttCssStyle style = styleMatches.get(i).style; - if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { + if (style.getRubyPosition() != TextAnnotation.POSITION_UNKNOWN) { return style.getRubyPosition(); } } - return RubySpan.POSITION_UNKNOWN; + return TextAnnotation.POSITION_UNKNOWN; } - @RubySpan.Position + @TextAnnotation.Position private static int firstKnownRubyPosition( - @RubySpan.Position int position1, - @RubySpan.Position int position2, - @RubySpan.Position int position3) { - if (position1 != RubySpan.POSITION_UNKNOWN) { + @TextAnnotation.Position int position1, + @TextAnnotation.Position int position2, + @TextAnnotation.Position int position3) { + if (position1 != TextAnnotation.POSITION_UNKNOWN) { return position1; } - if (position2 != RubySpan.POSITION_UNKNOWN) { + if (position2 != TextAnnotation.POSITION_UNKNOWN) { return position2; } - if (position3 != RubySpan.POSITION_UNKNOWN) { + if (position3 != TextAnnotation.POSITION_UNKNOWN) { return position3; } throw new IllegalArgumentException(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java new file mode 100644 index 0000000000..7016bfa465 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TextEmphasisTest.java @@ -0,0 +1,729 @@ +/* + * Copyright 2021 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 static com.google.android.exoplayer2.text.ttml.TextEmphasis.MARK_SHAPE_AUTO; +import static com.google.android.exoplayer2.text.ttml.TextEmphasis.POSITION_OUTSIDE; +import static com.google.android.exoplayer2.text.ttml.TextEmphasis.parse; +import static com.google.common.truth.Truth.assertWithMessage; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link TextEmphasis}. */ +@RunWith(AndroidJUnit4.class) +public class TextEmphasisTest { + + @Test + public void testNull() { + @Nullable TextEmphasis textEmphasis = parse(null); + assertWithMessage("Text Emphasis must be null").that(textEmphasis).isNull(); + } + + @Test + public void testEmpty() { + @Nullable TextEmphasis textEmphasis = parse(""); + assertWithMessage("Text Emphasis must be null").that(textEmphasis).isNull(); + } + + @Test + public void testEmptyWithWhitespace() { + @Nullable TextEmphasis textEmphasis = parse(" "); + assertWithMessage("Text Emphasis must be null").that(textEmphasis).isNull(); + } + + @Test + public void testNone() { + String value = "none"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_NONE); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testAuto() { + String value = "auto"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape").that(textEmphasis.markShape).isEqualTo(MARK_SHAPE_AUTO); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_UNKNOWN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testInvalid() { + String value = "invalid"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape").that(textEmphasis.markShape).isEqualTo(MARK_SHAPE_AUTO); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_UNKNOWN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testAutoOutside() { + String value = "auto outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape").that(textEmphasis.markShape).isEqualTo(MARK_SHAPE_AUTO); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_UNKNOWN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testAutoAfter() { + String value = "auto after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape").that(textEmphasis.markShape).isEqualTo(MARK_SHAPE_AUTO); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_UNKNOWN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + /** + * If only filled or open is specified, then it is equivalent to filled circle and open circle, + * respectively. + */ + @Test + public void testFilled() { + String value = "filled"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testOpen() { + String value = "open"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testOpenAfter() { + String value = "open after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + /** + * If only circle, dot, or sesame is specified, then it is equivalent to filled circle, filled + * dot, and filled sesame, respectively. + */ + @Test + public void testDotBefore() { + String value = "dot before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testCircleBefore() { + String value = "circle before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testSesameBefore() { + String value = "sesame before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testDotAfter() { + String value = "dot after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testCircleAfter() { + String value = "circle after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testSesameAfter() { + String value = "sesame after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testDotOutside() { + String value = "dot outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testCircleOutside() { + String value = "circle outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testSesameOutside() { + String value = "sesame outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testOpenDotAfter() { + String value = "open dot after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testOpenCircleAfter() { + String value = "open circle after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testOpenSesameAfter() { + String value = "open sesame after"; + @Nullable TextEmphasis textEmphasis = parse(value); + + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testOpenDotBefore() { + String value = "open dot before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testOpenCircleBefore() { + String value = "open circle before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testOpenSesameBefore() { + String value = "open sesame before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testOpenDotOutside() { + String value = "open dot Outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testOpenCircleOutside() { + String value = "open circle outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testOpenSesameOutside() { + String value = "open sesame outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testFilledDotOutside() { + String value = "filled dot outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testFilledCircleOutside() { + String value = "filled circle outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testFilledSesameOutside() { + String value = "filled sesame outside"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextEmphasis.POSITION_OUTSIDE); + } + + @Test + public void testFilledDotAfter() { + String value = "filled dot after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testFilledCircleAfter() { + String value = "filled circle after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testFilledSesameAfter() { + String value = "filled sesame after"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testFilledDotBefore() { + String value = "filled dot before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testFilledCircleBefore() { + String value = "filled circle before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testFilledSesameBefore() { + String value = "filled sesame before"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testBeforeFilledSesame() { + String value = "before filled sesame"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testBeforeSesameFilled() { + String value = "before sesame filled"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testInvalidMarkShape() { + String value = "before sesamee filled"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_CIRCLE); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testInvalidMarkFill() { + String value = "before sesame filed"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_BEFORE); + } + + @Test + public void testInvalidPosition() { + String value = "befour sesame filled"; + @Nullable TextEmphasis textEmphasis = parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertWithMessage("position").that(textEmphasis.position).isEqualTo(POSITION_OUTSIDE); + } + + @Test + public void testValidMixedWithInvalidDescription() { + String value = "blue open sesame foo bar after"; + @Nullable TextEmphasis textEmphasis = TextEmphasis.parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape") + .that(textEmphasis.markShape) + .isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } + + @Test + public void testColorDescriptionNotSupported() { + String value = "blue"; + @Nullable TextEmphasis textEmphasis = TextEmphasis.parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape").that(textEmphasis.markShape).isEqualTo(MARK_SHAPE_AUTO); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_UNKNOWN); + assertWithMessage("position").that(textEmphasis.position).isEqualTo(POSITION_OUTSIDE); + } + + @Test + public void testQuotedStringStyleNotSupported() { + String value = "\"x\" after"; + @Nullable TextEmphasis textEmphasis = TextEmphasis.parse(value); + assertWithMessage("Text Emphasis must exist").that(textEmphasis).isNotNull(); + assertWithMessage("markShape").that(textEmphasis.markShape).isEqualTo(MARK_SHAPE_AUTO); + assertWithMessage("markFill") + .that(textEmphasis.markFill) + .isEqualTo(TextEmphasisSpan.MARK_FILL_UNKNOWN); + assertWithMessage("position") + .that(textEmphasis.position) + .isEqualTo(TextAnnotation.POSITION_AFTER); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java index dac21f3628..e3913670a1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java @@ -27,7 +27,8 @@ import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.text.Cue; 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.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import java.io.IOException; @@ -67,6 +68,7 @@ public final class TtmlDecoderTest { private static final String VERTICAL_TEXT_FILE = "media/ttml/vertical_text.xml"; private static final String TEXT_COMBINE_FILE = "media/ttml/text_combine.xml"; private static final String RUBIES_FILE = "media/ttml/rubies.xml"; + private static final String TEXT_EMPHASIS_FILE = "media/ttml/text_emphasis.xml"; @Test public void inlineAttributes() throws IOException, SubtitleDecoderException { @@ -109,12 +111,12 @@ public final class TtmlDecoderTest { * framework level. Tests that lime resolves to #FF00FF00 not #00FF00 * . * + * @throws IOException thrown if reading subtitle file fails. * @see * JellyBean Color * Kitkat Color - * @throws IOException thrown if reading subtitle file fails. */ @Test public void lime() throws IOException, SubtitleDecoderException { @@ -646,16 +648,16 @@ public final class TtmlDecoderTest { assertThat(firstCue.toString()).isEqualTo("Cue with annotated text."); assertThat(firstCue) .hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length()) - .withTextAndPosition("1st rubies", RubySpan.POSITION_OVER); + .withTextAndPosition("1st rubies", TextAnnotation.POSITION_BEFORE); assertThat(firstCue) .hasRubySpanBetween("Cue with annotated ".length(), "Cue with annotated text".length()) - .withTextAndPosition("2nd rubies", RubySpan.POSITION_UNKNOWN); + .withTextAndPosition("2nd rubies", TextAnnotation.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); + .withTextAndPosition("rubies", TextAnnotation.POSITION_UNKNOWN); Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000); assertThat(thirdCue.toString()).isEqualTo("Cue with annotated text."); @@ -674,6 +676,146 @@ public final class TtmlDecoderTest { assertThat(sixthCue).hasNoRubySpanBetween(0, sixthCue.length()); } + @Test + public void textEmphasis() throws IOException, SubtitleDecoderException { + TtmlSubtitle subtitle = getSubtitle(TEXT_EMPHASIS_FILE); + + Spanned firstCue = getOnlyCueTextAtTimeUs(subtitle, 10_000_000); + assertThat(firstCue) + .hasTextEmphasisSpanBetween("None ".length(), "None おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_NONE, + TextEmphasisSpan.MARK_FILL_UNKNOWN, + TextAnnotation.POSITION_BEFORE); + + Spanned secondCue = getOnlyCueTextAtTimeUs(subtitle, 20_000_000); + assertThat(secondCue) + .hasTextEmphasisSpanBetween("Auto ".length(), "Auto ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000); + assertThat(thirdCue) + .hasTextEmphasisSpanBetween("Filled circle ".length(), "Filled circle こんばんは".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned fourthCue = getOnlyCueTextAtTimeUs(subtitle, 40_000_000); + assertThat(fourthCue) + .hasTextEmphasisSpanBetween("Filled dot ".length(), "Filled dot ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_DOT, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned fifthCue = getOnlyCueTextAtTimeUs(subtitle, 50_000_000); + assertThat(fifthCue) + .hasTextEmphasisSpanBetween("Filled sesame ".length(), "Filled sesame おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned sixthCue = getOnlyCueTextAtTimeUs(subtitle, 60_000_000); + assertThat(sixthCue) + .hasTextEmphasisSpanBetween( + "Open circle before ".length(), "Open circle before ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_OPEN, + TextAnnotation.POSITION_BEFORE); + + Spanned seventhCue = getOnlyCueTextAtTimeUs(subtitle, 70_000_000); + assertThat(seventhCue) + .hasTextEmphasisSpanBetween("Open dot after ".length(), "Open dot after おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_DOT, + TextEmphasisSpan.MARK_FILL_OPEN, + TextAnnotation.POSITION_AFTER); + + Spanned eighthCue = getOnlyCueTextAtTimeUs(subtitle, 80_000_000); + assertThat(eighthCue) + .hasTextEmphasisSpanBetween( + "Open sesame outside ".length(), "Open sesame outside ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_OPEN, + TextAnnotation.POSITION_BEFORE); + + Spanned ninthCue = getOnlyCueTextAtTimeUs(subtitle, 90_000_000); + assertThat(ninthCue) + .hasTextEmphasisSpanBetween("Auto outside ".length(), "Auto outside おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned tenthCue = getOnlyCueTextAtTimeUs(subtitle, 100_000_000); + assertThat(tenthCue) + .hasTextEmphasisSpanBetween("Circle before ".length(), "Circle before ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned eleventhCue = getOnlyCueTextAtTimeUs(subtitle, 110_000_000); + assertThat(eleventhCue) + .hasTextEmphasisSpanBetween("Sesame after ".length(), "Sesame after おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER); + + Spanned twelfthCue = getOnlyCueTextAtTimeUs(subtitle, 120_000_000); + assertThat(twelfthCue) + .hasTextEmphasisSpanBetween("Dot outside ".length(), "Dot outside ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_DOT, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned thirteenthCue = getOnlyCueTextAtTimeUs(subtitle, 130_000_000); + assertThat(thirteenthCue) + .hasNoTextEmphasisSpanBetween( + "No textEmphasis property ".length(), "No textEmphasis property おはよ".length()); + + Spanned fourteenthCue = getOnlyCueTextAtTimeUs(subtitle, 140_000_000); + assertThat(fourteenthCue) + .hasTextEmphasisSpanBetween("Auto (TBLR) ".length(), "Auto (TBLR) ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned fifteenthCue = getOnlyCueTextAtTimeUs(subtitle, 150_000_000); + assertThat(fifteenthCue) + .hasTextEmphasisSpanBetween("Auto (TBRL) ".length(), "Auto (TBRL) おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned sixteenthCue = getOnlyCueTextAtTimeUs(subtitle, 160_000_000); + assertThat(sixteenthCue) + .hasTextEmphasisSpanBetween("Auto (TB) ".length(), "Auto (TB) ございます".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + + Spanned seventeenthCue = getOnlyCueTextAtTimeUs(subtitle, 170_000_000); + assertThat(seventeenthCue) + .hasTextEmphasisSpanBetween("Auto (LR) ".length(), "Auto (LR) おはよ".length()) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE); + } + private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) { Cue cue = getOnlyCueAtTimeUs(subtitle, timeUs); assertThat(cue.text).isInstanceOf(Spanned.class); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java index a3ad1ba599..6b57108393 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.text.ttml; import static android.graphics.Color.BLACK; +import static com.google.android.exoplayer2.text.span.TextAnnotation.POSITION_BEFORE; import static com.google.android.exoplayer2.text.ttml.TtmlStyle.STYLE_BOLD; import static com.google.android.exoplayer2.text.ttml.TtmlStyle.STYLE_BOLD_ITALIC; import static com.google.android.exoplayer2.text.ttml.TtmlStyle.STYLE_ITALIC; @@ -28,7 +29,8 @@ import android.graphics.Color; import android.text.Layout; import androidx.annotation.ColorInt; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,9 +45,10 @@ public final class TtmlStyleTest { @TtmlStyle.FontSizeUnit private static final int FONT_SIZE_UNIT = TtmlStyle.FONT_SIZE_UNIT_EM; @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 int RUBY_POSITION = TextAnnotation.POSITION_AFTER; private static final Layout.Alignment TEXT_ALIGN = Layout.Alignment.ALIGN_CENTER; private static final boolean TEXT_COMBINE = true; + public static final String TEXT_EMPHASIS_STYLE = "dot before"; private final TtmlStyle populatedStyle = new TtmlStyle() @@ -62,7 +65,8 @@ public final class TtmlStyleTest { .setRubyType(RUBY_TYPE) .setRubyPosition(RUBY_POSITION) .setTextAlign(TEXT_ALIGN) - .setTextCombine(TEXT_COMBINE); + .setTextCombine(TEXT_COMBINE) + .setTextEmphasis(TextEmphasis.parse(TEXT_EMPHASIS_STYLE)); @Test public void inheritStyle() { @@ -86,6 +90,10 @@ public final class TtmlStyleTest { assertWithMessage("backgroundColor should not be inherited") .that(style.hasBackgroundColor()) .isFalse(); + assertThat(style.getTextEmphasis()).isNotNull(); + assertThat(style.getTextEmphasis().markShape).isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertThat(style.getTextEmphasis().markFill).isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertThat(style.getTextEmphasis().position).isEqualTo(POSITION_BEFORE); } @Test @@ -109,6 +117,10 @@ public final class TtmlStyleTest { .that(style.getBackgroundColor()) .isEqualTo(BACKGROUND_COLOR); assertWithMessage("rubyType should be chained").that(style.getRubyType()).isEqualTo(RUBY_TYPE); + assertThat(style.getTextEmphasis()).isNotNull(); + assertThat(style.getTextEmphasis().markShape).isEqualTo(TextEmphasisSpan.MARK_SHAPE_DOT); + assertThat(style.getTextEmphasis().markFill).isEqualTo(TextEmphasisSpan.MARK_FILL_FILLED); + assertThat(style.getTextEmphasis().position).isEqualTo(POSITION_BEFORE); } @Test @@ -221,9 +233,9 @@ public final class TtmlStyleTest { public void rubyPosition() { TtmlStyle style = new TtmlStyle(); - assertThat(style.getRubyPosition()).isEqualTo(RubySpan.POSITION_UNKNOWN); - style.setRubyPosition(RubySpan.POSITION_OVER); - assertThat(style.getRubyPosition()).isEqualTo(RubySpan.POSITION_OVER); + assertThat(style.getRubyPosition()).isEqualTo(TextAnnotation.POSITION_UNKNOWN); + style.setRubyPosition(POSITION_BEFORE); + assertThat(style.getRubyPosition()).isEqualTo(POSITION_BEFORE); } @Test @@ -245,4 +257,14 @@ public final class TtmlStyleTest { style.setTextCombine(true); assertThat(style.getTextCombine()).isTrue(); } + + @Test + public void textEmphasis() { + TtmlStyle style = new TtmlStyle(); + assertThat(style.getTextEmphasis()).isNull(); + style.setTextEmphasis(TextEmphasis.parse("open sesame after")); + assertThat(style.getTextEmphasis().markShape).isEqualTo(TextEmphasisSpan.MARK_SHAPE_SESAME); + assertThat(style.getTextEmphasis().markFill).isEqualTo(TextEmphasisSpan.MARK_FILL_OPEN); + assertThat(style.getTextEmphasis().position).isEqualTo(TextAnnotation.POSITION_AFTER); + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java index 9b7db097a7..eb565b09f5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java @@ -26,7 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.SubtitleDecoderException; -import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.common.collect.Iterables; @@ -349,7 +349,7 @@ public class WebvttDecoderTest { assertThat(firstCue.text.toString()).isEqualTo("Some text with over-ruby."); assertThat((Spanned) firstCue.text) .hasRubySpanBetween("Some ".length(), "Some text with over-ruby".length()) - .withTextAndPosition("over", RubySpan.POSITION_OVER); + .withTextAndPosition("over", TextAnnotation.POSITION_BEFORE); // Check that `under` is read from CSS and unspecified defaults to `over`. Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2))); @@ -357,25 +357,25 @@ public class WebvttDecoderTest { .isEqualTo("Some text with under-ruby and over-ruby (default)."); assertThat((Spanned) secondCue.text) .hasRubySpanBetween("Some ".length(), "Some text with under-ruby".length()) - .withTextAndPosition("under", RubySpan.POSITION_UNDER); + .withTextAndPosition("under", TextAnnotation.POSITION_AFTER); assertThat((Spanned) secondCue.text) .hasRubySpanBetween( "Some text with under-ruby and ".length(), "Some text with under-ruby and over-ruby (default)".length()) - .withTextAndPosition("over", RubySpan.POSITION_OVER); + .withTextAndPosition("over", TextAnnotation.POSITION_BEFORE); // Check many tags with different positions nested in a single span. Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); assertThat(thirdCue.text.toString()).isEqualTo("base1base2base3."); assertThat((Spanned) thirdCue.text) .hasRubySpanBetween(/* start= */ 0, "base1".length()) - .withTextAndPosition("over1", RubySpan.POSITION_OVER); + .withTextAndPosition("over1", TextAnnotation.POSITION_BEFORE); assertThat((Spanned) thirdCue.text) .hasRubySpanBetween("base1".length(), "base1base2".length()) - .withTextAndPosition("under2", RubySpan.POSITION_UNDER); + .withTextAndPosition("under2", TextAnnotation.POSITION_AFTER); assertThat((Spanned) thirdCue.text) .hasRubySpanBetween("base1base2".length(), "base1base2base3".length()) - .withTextAndPosition("under3", RubySpan.POSITION_UNDER); + .withTextAndPosition("under3", TextAnnotation.POSITION_AFTER); // Check a span with no tags. Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java index 7ea2b55cf4..20745563ad 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java @@ -31,6 +31,8 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableMap; @@ -186,17 +188,26 @@ import java.util.regex.Pattern; } else if (span instanceof RubySpan) { RubySpan rubySpan = (RubySpan) span; switch (rubySpan.position) { - case RubySpan.POSITION_OVER: + case TextAnnotation.POSITION_BEFORE: return ""; - case RubySpan.POSITION_UNDER: + case TextAnnotation.POSITION_AFTER: return ""; - case RubySpan.POSITION_UNKNOWN: + case TextAnnotation.POSITION_UNKNOWN: return ""; default: return null; } } else if (span instanceof UnderlineSpan) { return ""; + } else if (span instanceof TextEmphasisSpan) { + TextEmphasisSpan textEmphasisSpan = (TextEmphasisSpan) span; + String style = getTextEmphasisStyle(textEmphasisSpan.markShape, textEmphasisSpan.markFill); + String position = getTextEmphasisPosition(textEmphasisSpan.position); + return Util.formatInvariant( + "", + style, position); } else { return null; } @@ -209,7 +220,8 @@ import java.util.regex.Pattern; || span instanceof BackgroundColorSpan || span instanceof HorizontalTextInVerticalContextSpan || span instanceof AbsoluteSizeSpan - || span instanceof RelativeSizeSpan) { + || span instanceof RelativeSizeSpan + || span instanceof TextEmphasisSpan) { return ""; } else if (span instanceof TypefaceSpan) { @Nullable String fontFamily = ((TypefaceSpan) span).getFamily(); @@ -232,6 +244,52 @@ import java.util.regex.Pattern; return null; } + private static String getTextEmphasisStyle( + @TextEmphasisSpan.MarkShape int shape, @TextEmphasisSpan.MarkFill int fill) { + StringBuilder builder = new StringBuilder(); + switch (fill) { + case TextEmphasisSpan.MARK_FILL_FILLED: + builder.append("filled "); + break; + case TextEmphasisSpan.MARK_FILL_OPEN: + builder.append("open "); + break; + case TextEmphasisSpan.MARK_FILL_UNKNOWN: + default: + break; + } + + switch (shape) { + case TextEmphasisSpan.MARK_SHAPE_CIRCLE: + builder.append("circle"); + break; + case TextEmphasisSpan.MARK_SHAPE_DOT: + builder.append("dot"); + break; + case TextEmphasisSpan.MARK_SHAPE_SESAME: + builder.append("sesame"); + break; + case TextEmphasisSpan.MARK_SHAPE_NONE: + builder.append("none"); + break; + default: + builder.append("unset"); + break; + } + return builder.toString(); + } + + private static String getTextEmphasisPosition(@TextAnnotation.Position int position) { + switch (position) { + case TextAnnotation.POSITION_AFTER: + return "under left"; + case TextAnnotation.POSITION_UNKNOWN: + case TextAnnotation.POSITION_BEFORE: + default: + return "over right"; + } + } + private static Transition getOrCreate(SparseArray transitions, int key) { @Nullable Transition transition = transitions.get(key); if (transition == null) { diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java index b9eb6d8e6a..09efaad709 100644 --- a/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverterTest.java @@ -34,6 +34,8 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; @@ -250,12 +252,12 @@ public class SpannedToHtmlConverterTest { SpannableString spanned = new SpannableString("String with over-annotated and under-annotated section"); spanned.setSpan( - new RubySpan("ruby-text", RubySpan.POSITION_OVER), + new RubySpan("ruby-text", TextAnnotation.POSITION_BEFORE), "String with ".length(), "String with over-annotated".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); spanned.setSpan( - new RubySpan("non-àscìì-text", RubySpan.POSITION_UNDER), + new RubySpan("non-àscìì-text", TextAnnotation.POSITION_AFTER), "String with over-annotated and ".length(), "String with over-annotated and under-annotated".length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -279,6 +281,42 @@ public class SpannedToHtmlConverterTest { + "section"); } + @Test + public void convert_supportsTextEmphasisSpan() { + SpannableString spanned = new SpannableString("Text emphasis おはよ ございます"); + spanned.setSpan( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE), + "Text emphasis ".length(), + "Text emphasis おはよ".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + spanned.setSpan( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_OPEN, + TextAnnotation.POSITION_AFTER), + "Text emphasis おはよ ".length(), + "Text emphasis おはよ ございます".length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert(spanned, displayDensity); + + assertThat(htmlAndCss.cssRuleSets).isEmpty(); + assertThat(htmlAndCss.html) + .isEqualTo( + "Text emphasis おはよ ございます"); + } + @Test public void convert_supportsUnderlineSpan() { SpannableString spanned = new SpannableString("String with underlined section."); diff --git a/testdata/src/test/assets/media/ttml/text_emphasis.xml b/testdata/src/test/assets/media/ttml/text_emphasis.xml new file mode 100644 index 0000000000..3f56704a37 --- /dev/null +++ b/testdata/src/test/assets/media/ttml/text_emphasis.xml @@ -0,0 +1,65 @@ + + + + + + + + +
+

None おはよ

+
+
+

Auto ございます

+
+
+

Filled circle こんばんは

+
+
+

Filled dot ございます

+
+
+

Filled sesame おはよ

+
+
+

Open circle before ございます

+
+
+

Open dot after おはよ

+
+
+

Open sesame outside ございます

+
+
+

Auto outside おはよ

+
+
+

Circle before ございます

+
+
+

Sesame after おはよ

+
+
+

Dot outside ございます

+
+
+

No textEmphasis property おはよ

+
+
+

Auto (TBLR) ございます

+
+
+

Auto (TBRL) おはよ

+
+
+

Auto (TB) ございます

+
+
+

Auto (LR) おはよ

+
+ + +
diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java index a980254277..c156d24779 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java @@ -38,6 +38,8 @@ import androidx.annotation.ColorInt; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; import com.google.android.exoplayer2.util.Util; import com.google.common.truth.Fact; import com.google.common.truth.FailureMetadata; @@ -578,6 +580,44 @@ public final class SpannedSubject extends Subject { return ALREADY_FAILED_WITH_FLAGS; } + /** + * Checks that the subject has an {@link TextEmphasisSpan} from {@code start} to {@code end}. + * + * @param start The start of the expected span. + * @param end The end of the expected span. + * @return A {@link EmphasizedText} object for optional additional assertions on the flags. + */ + public EmphasizedText hasTextEmphasisSpanBetween(int start, int end) { + if (actual == null) { + failWithoutActual(simpleFact("Spanned must not be null")); + return ALREADY_FAILED_WITH_MARK_AND_POSITION; + } + + List textEmphasisSpans = + findMatchingSpans(start, end, TextEmphasisSpan.class); + if (textEmphasisSpans.size() == 1) { + return check("TextEmphasisSpan (start=%s,end=%s)", start, end) + .about(textEmphasisSubjects(actual)) + .that(textEmphasisSpans); + } + failWithExpectedSpan( + start, end, TextEmphasisSpan.class, actual.toString().substring(start, end)); + return ALREADY_FAILED_WITH_MARK_AND_POSITION; + } + + /** + * Checks that the subject has no {@link TextEmphasisSpan}s on any of the text between {@code + * start} and {@code end}. + * + *

This fails even if the start and end indexes don't exactly match. + * + * @param start The start index to start searching for spans. + * @param end The end index to stop searching for spans. + */ + public void hasNoTextEmphasisSpanBetween(int start, int end) { + hasNoSpansOfTypeBetween(TextEmphasisSpan.class, start, end); + } + /** * Checks that the subject has no {@link HorizontalTextInVerticalContextSpan}s on any of the text * between {@code start} and {@code end}. @@ -1033,7 +1073,7 @@ public final class SpannedSubject extends Subject { * @param position The expected position of the text. * @return A {@link WithSpanFlags} object for optional additional assertions on the flags. */ - AndSpanFlags withTextAndPosition(String text, @RubySpan.Position int position); + AndSpanFlags withTextAndPosition(String text, @TextAnnotation.Position int position); } private static final RubyText ALREADY_FAILED_WITH_TEXT = @@ -1057,7 +1097,7 @@ public final class SpannedSubject extends Subject { } @Override - public AndSpanFlags withTextAndPosition(String text, @RubySpan.Position int position) { + public AndSpanFlags withTextAndPosition(String text, @TextAnnotation.Position int position) { List matchingSpanFlags = new ArrayList<>(); List spanTextsAndPositions = new ArrayList<>(); for (RubySpan span : actualSpans) { @@ -1074,7 +1114,7 @@ public final class SpannedSubject extends Subject { private static final class TextAndPosition { private final String text; - @RubySpan.Position private final int position; + @TextAnnotation.Position private final int position; private TextAndPosition(String text, int position) { this.text = text; @@ -1110,4 +1150,108 @@ public final class SpannedSubject extends Subject { } } } + + /** Allows assertions about a span's text emphasis mark and its position. */ + public interface EmphasizedText { + /** + * Checks that at least one of the matched spans has the expected {@code mark} and {@code + * position}. + * + * @param markShape The expected mark shape. + * @param markFill The expected mark fill style. + * @param position The expected position of the mark. + * @return A {@link AndSpanFlags} object for optional additional assertions on the flags. + */ + AndSpanFlags withMarkAndPosition( + @TextEmphasisSpan.MarkShape int markShape, + @TextEmphasisSpan.MarkFill int markFill, + @TextAnnotation.Position int position); + } + + private static final EmphasizedText ALREADY_FAILED_WITH_MARK_AND_POSITION = + (markShape, markFill, position) -> ALREADY_FAILED_AND_FLAGS; + + private static Factory> textEmphasisSubjects( + Spanned actualSpanned) { + return (FailureMetadata metadata, List spans) -> + new TextEmphasisSubject(metadata, spans, actualSpanned); + } + + private static final class TextEmphasisSubject extends Subject implements EmphasizedText { + + private final List actualSpans; + private final Spanned actualSpanned; + + private TextEmphasisSubject( + FailureMetadata metadata, List actualSpans, Spanned actualSpanned) { + super(metadata, actualSpans); + this.actualSpans = actualSpans; + this.actualSpanned = actualSpanned; + } + + @Override + public AndSpanFlags withMarkAndPosition( + @TextEmphasisSpan.MarkShape int markShape, + @TextEmphasisSpan.MarkFill int markFill, + @TextAnnotation.Position int position) { + List matchingSpanFlags = new ArrayList<>(); + List textEmphasisMarksAndPositions = new ArrayList<>(); + for (TextEmphasisSpan span : actualSpans) { + textEmphasisMarksAndPositions.add( + new MarkAndPosition(span.markShape, span.markFill, span.position)); + if (span.markFill == markFill && span.markShape == markShape && span.position == position) { + matchingSpanFlags.add(actualSpanned.getSpanFlags(span)); + } + } + check("textEmphasisMarkAndPosition") + .that(textEmphasisMarksAndPositions) + .containsExactly(new MarkAndPosition(markShape, markFill, position)); + return check("flags").about(spanFlags()).that(matchingSpanFlags); + } + + private static final class MarkAndPosition { + + @TextEmphasisSpan.MarkShape private final int markShape; + @TextEmphasisSpan.MarkFill private final int markFill; + @TextAnnotation.Position private final int position; + + private MarkAndPosition( + @TextEmphasisSpan.MarkShape int markShape, + @TextEmphasisSpan.MarkFill int markFill, + @TextAnnotation.Position int position) { + this.markFill = markFill; + this.markShape = markShape; + this.position = position; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + TextEmphasisSubject.MarkAndPosition that = (TextEmphasisSubject.MarkAndPosition) o; + return (position == that.position) + && (markShape == that.markShape) + && (markFill == that.markFill); + } + + @Override + public int hashCode() { + int result = markShape; + result = 31 * result + markFill; + result = 31 * result + position; + return result; + } + + @Override + public String toString() { + return String.format( + "{markShape=%s,markFill=%s,position=%s}", markShape, markFill, position); + } + } + } } diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java index 75495a4293..aaa399a47d 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java @@ -41,6 +41,9 @@ import com.google.android.exoplayer2.testutil.truth.SpannedSubject.AndSpanFlags; import com.google.android.exoplayer2.testutil.truth.SpannedSubject.WithSpanFlags; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; import com.google.android.exoplayer2.text.span.RubySpan; +import com.google.android.exoplayer2.text.span.TextAnnotation; +import com.google.android.exoplayer2.text.span.TextEmphasisSpan; +import com.google.android.exoplayer2.util.Util; import com.google.common.truth.ExpectFailure; import org.junit.Test; import org.junit.runner.RunWith; @@ -607,23 +610,26 @@ public class SpannedSubjectTest { public void rubySpan_success() { SpannableString spannable = createSpannable( - new RubySpan("ruby text", RubySpan.POSITION_OVER), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); assertThat(spannable) .hasRubySpanBetween(SPAN_START, SPAN_END) - .withTextAndPosition("ruby text", RubySpan.POSITION_OVER) + .withTextAndPosition("ruby text", TextAnnotation.POSITION_BEFORE) .andFlags(Spanned.SPAN_INCLUSIVE_EXCLUSIVE); } @Test public void rubySpan_wrongEndIndex() { checkHasSpanFailsDueToIndexMismatch( - new RubySpan("ruby text", RubySpan.POSITION_OVER), SpannedSubject::hasRubySpanBetween); + new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE), + SpannedSubject::hasRubySpanBetween); } @Test public void rubySpan_wrongText() { - SpannableString spannable = createSpannable(new RubySpan("ruby text", RubySpan.POSITION_OVER)); + SpannableString spannable = + createSpannable(new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE)); AssertionError expected = expectFailure( @@ -631,7 +637,7 @@ public class SpannedSubjectTest { whenTesting .that(spannable) .hasRubySpanBetween(SPAN_START, SPAN_END) - .withTextAndPosition("incorrect text", RubySpan.POSITION_OVER)); + .withTextAndPosition("incorrect text", TextAnnotation.POSITION_BEFORE)); assertThat(expected).factValue("value of").contains("rubyTextAndPosition"); assertThat(expected).factValue("expected").contains("text='incorrect text'"); @@ -640,7 +646,8 @@ public class SpannedSubjectTest { @Test public void rubySpan_wrongPosition() { - SpannableString spannable = createSpannable(new RubySpan("ruby text", RubySpan.POSITION_OVER)); + SpannableString spannable = + createSpannable(new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE)); AssertionError expected = expectFailure( @@ -648,27 +655,32 @@ public class SpannedSubjectTest { whenTesting .that(spannable) .hasRubySpanBetween(SPAN_START, SPAN_END) - .withTextAndPosition("ruby text", RubySpan.POSITION_UNDER)); + .withTextAndPosition("ruby text", TextAnnotation.POSITION_AFTER)); assertThat(expected).factValue("value of").contains("rubyTextAndPosition"); - assertThat(expected).factValue("expected").contains("position=" + RubySpan.POSITION_UNDER); - assertThat(expected).factValue("but was").contains("position=" + RubySpan.POSITION_OVER); + assertThat(expected) + .factValue("expected") + .contains("position=" + TextAnnotation.POSITION_AFTER); + assertThat(expected) + .factValue("but was") + .contains("position=" + TextAnnotation.POSITION_BEFORE); } @Test public void rubySpan_wrongFlags() { checkHasSpanFailsDueToFlagMismatch( - new RubySpan("ruby text", RubySpan.POSITION_OVER), + new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE), (subject, start, end) -> subject .hasRubySpanBetween(start, end) - .withTextAndPosition("ruby text", RubySpan.POSITION_OVER)); + .withTextAndPosition("ruby text", TextAnnotation.POSITION_BEFORE)); } @Test public void noRubySpan_success() { SpannableString spannable = - createSpannableWithUnrelatedSpanAnd(new RubySpan("ruby text", RubySpan.POSITION_OVER)); + createSpannableWithUnrelatedSpanAnd( + new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE)); assertThat(spannable).hasNoRubySpanBetween(UNRELATED_SPAN_START, UNRELATED_SPAN_END); } @@ -676,7 +688,191 @@ public class SpannedSubjectTest { @Test public void noRubySpan_failure() { checkHasNoSpanFails( - new RubySpan("ruby text", RubySpan.POSITION_OVER), SpannedSubject::hasNoRubySpanBetween); + new RubySpan("ruby text", TextAnnotation.POSITION_BEFORE), + SpannedSubject::hasNoRubySpanBetween); + } + + @Test + public void textEmphasis_success() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + + assertThat(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER) + .andFlags(Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + @Test + public void textEmphasis_wrongIndex() { + checkHasSpanFailsDueToIndexMismatch( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER), + SpannedSubject::hasTextEmphasisSpanBetween); + } + + @Test + public void textEmphasis_wrongMarkShape() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + + AssertionError expected = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + + assertThat(expected).factValue("value of").contains("textEmphasisMarkAndPosition"); + assertThat(expected) + .factValue("expected") + .contains( + Util.formatInvariant( + "{markShape=%d,markFill=%d,position=%d}", + TextEmphasisSpan.MARK_SHAPE_SESAME, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + assertThat(expected) + .factValue("but was") + .contains( + Util.formatInvariant( + "{markShape=%d,markFill=%d,position=%d}", + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + } + + @Test + public void textEmphasis_wrongMarkFill() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + + AssertionError expected = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_OPEN, + TextAnnotation.POSITION_AFTER)); + + assertThat(expected).factValue("value of").contains("textEmphasisMarkAndPosition"); + assertThat(expected) + .factValue("expected") + .contains( + Util.formatInvariant( + "{markShape=%d,markFill=%d,position=%d}", + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_OPEN, + TextAnnotation.POSITION_AFTER)); + assertThat(expected) + .factValue("but was") + .contains( + Util.formatInvariant( + "{markShape=%d,markFill=%d,position=%d}", + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + } + + @Test + public void textEmphasis_wrongPosition() { + SpannableString spannable = + createSpannable( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE)); + + AssertionError expected = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasTextEmphasisSpanBetween(SPAN_START, SPAN_END) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + + assertThat(expected).factValue("value of").contains("textEmphasisMarkAndPosition"); + assertThat(expected) + .factValue("expected") + .contains( + Util.formatInvariant( + "{markShape=%d,markFill=%d,position=%d}", + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + assertThat(expected) + .factValue("but was") + .contains( + Util.formatInvariant( + "{markShape=%d,markFill=%d,position=%d}", + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_BEFORE)); + } + + @Test + public void textEmphasis_wrongFlags() { + checkHasSpanFailsDueToFlagMismatch( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER), + (subject, start, end) -> + subject + .hasTextEmphasisSpanBetween(start, end) + .withMarkAndPosition( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + } + + @Test + public void noTextEmphasis_success() { + SpannableString spannable = + createSpannableWithUnrelatedSpanAnd( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER)); + + assertThat(spannable).hasNoTextEmphasisSpanBetween(UNRELATED_SPAN_START, UNRELATED_SPAN_END); + } + + @Test + public void noTextEmphasis_failure() { + checkHasNoSpanFails( + new TextEmphasisSpan( + TextEmphasisSpan.MARK_SHAPE_CIRCLE, + TextEmphasisSpan.MARK_FILL_FILLED, + TextAnnotation.POSITION_AFTER), + SpannedSubject::hasNoTextEmphasisSpanBetween); } @Test