diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index f06b00dba0..c517c8eeb5 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -26,6 +26,10 @@
* Ad playback / IMA:
* Decrease ad polling rate from every 100ms to every 200ms, to line up with
Media Rating Council (MRC) recommendations.
+* Text:
+ * SSA: Support `OutlineColour` style setting when `BorderStyle == 3` (i.e.
+ `OutlineColour` sets the background of the cue)
+ ([#8435](https://github.com/google/ExoPlayer/issues/8435)).
* Extractors:
* Matroska: Parse `DiscardPadding` for Opus tracks.
* Parse bitrates from `esds` boxes.
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaDecoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaDecoder.java
index 3871ac6676..050fbf36cf 100644
--- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaDecoder.java
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaDecoder.java
@@ -21,6 +21,7 @@ import static androidx.media3.common.util.Util.castNonNull;
import android.graphics.Typeface;
import android.text.Layout;
import android.text.SpannableString;
+import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
@@ -321,6 +322,13 @@ public final class SsaDecoder extends SimpleSubtitleDecoder {
/* end= */ spannableText.length(),
SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
}
+ if (style.borderStyle == SsaStyle.SSA_BORDER_STYLE_BOX && style.outlineColor != null) {
+ spannableText.setSpan(
+ new BackgroundColorSpan(style.outlineColor),
+ /* start= */ 0,
+ /* end= */ spannableText.length(),
+ SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
if (style.fontSize != Cue.DIMEN_UNSET && screenHeight != Cue.DIMEN_UNSET) {
cue.setTextSize(
style.fontSize / screenHeight, Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING);
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java
index 08461769f7..f644b08feb 100644
--- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java
@@ -92,32 +92,64 @@ import java.util.regex.Pattern;
public static final int SSA_ALIGNMENT_TOP_CENTER = 8;
public static final int SSA_ALIGNMENT_TOP_RIGHT = 9;
+ /**
+ * The SSA/ASS BorderStyle.
+ *
+ *
Allowed values:
+ *
+ *
+ * - {@link #SSA_BORDER_STYLE_UNKNOWN}
+ *
- {@link #SSA_BORDER_STYLE_OUTLINE}
+ *
- {@link #SSA_BORDER_STYLE_BOX}
+ *
+ */
+ @Target(TYPE_USE)
+ @IntDef({
+ SSA_BORDER_STYLE_UNKNOWN,
+ SSA_BORDER_STYLE_OUTLINE,
+ SSA_BORDER_STYLE_BOX,
+ })
+ @Documented
+ @Retention(SOURCE)
+ public @interface SsaBorderStyle {}
+
+ // The numbering follows the ASS (v4+) spec.
+ public static final int SSA_BORDER_STYLE_UNKNOWN = -1;
+ public static final int SSA_BORDER_STYLE_OUTLINE = 1;
+ public static final int SSA_BORDER_STYLE_BOX = 3;
+
public final String name;
public final @SsaAlignment int alignment;
@Nullable @ColorInt public final Integer primaryColor;
+ @Nullable @ColorInt public final Integer outlineColor;
public final float fontSize;
public final boolean bold;
public final boolean italic;
public final boolean underline;
public final boolean strikeout;
+ public final @SsaBorderStyle int borderStyle;
private SsaStyle(
String name,
@SsaAlignment int alignment,
@Nullable @ColorInt Integer primaryColor,
+ @Nullable @ColorInt Integer outlineColor,
float fontSize,
boolean bold,
boolean italic,
boolean underline,
- boolean strikeout) {
+ boolean strikeout,
+ @SsaBorderStyle int borderStyle) {
this.name = name;
this.alignment = alignment;
this.primaryColor = primaryColor;
+ this.outlineColor = outlineColor;
this.fontSize = fontSize;
this.bold = bold;
this.italic = italic;
this.underline = underline;
this.strikeout = strikeout;
+ this.borderStyle = borderStyle;
}
@Nullable
@@ -141,6 +173,9 @@ import java.util.regex.Pattern;
format.primaryColorIndex != C.INDEX_UNSET
? parseColor(styleValues[format.primaryColorIndex].trim())
: null,
+ format.outlineColorIndex != C.INDEX_UNSET
+ ? parseColor(styleValues[format.outlineColorIndex].trim())
+ : null,
format.fontSizeIndex != C.INDEX_UNSET
? parseFontSize(styleValues[format.fontSizeIndex].trim())
: Cue.DIMEN_UNSET,
@@ -151,7 +186,10 @@ import java.util.regex.Pattern;
format.underlineIndex != C.INDEX_UNSET
&& parseBooleanValue(styleValues[format.underlineIndex].trim()),
format.strikeoutIndex != C.INDEX_UNSET
- && parseBooleanValue(styleValues[format.strikeoutIndex].trim()));
+ && parseBooleanValue(styleValues[format.strikeoutIndex].trim()),
+ format.borderStyleIndex != C.INDEX_UNSET
+ ? parseBorderStyle(styleValues[format.borderStyleIndex].trim())
+ : SSA_BORDER_STYLE_UNKNOWN);
} catch (RuntimeException e) {
Log.w(TAG, "Skipping malformed 'Style:' line: '" + styleLine + "'", e);
return null;
@@ -189,6 +227,30 @@ import java.util.regex.Pattern;
}
}
+ private static @SsaBorderStyle int parseBorderStyle(String borderStyleStr) {
+ try {
+ @SsaBorderStyle int borderStyle = Integer.parseInt(borderStyleStr.trim());
+ if (isValidBorderStyle(borderStyle)) {
+ return borderStyle;
+ }
+ } catch (NumberFormatException e) {
+ // Swallow the exception and return UNKNOWN below.
+ }
+ Log.w(TAG, "Ignoring unknown BorderStyle: " + borderStyleStr);
+ return SSA_BORDER_STYLE_UNKNOWN;
+ }
+
+ private static boolean isValidBorderStyle(@SsaBorderStyle int alignment) {
+ switch (alignment) {
+ case SSA_BORDER_STYLE_OUTLINE:
+ case SSA_BORDER_STYLE_BOX:
+ return true;
+ case SSA_BORDER_STYLE_UNKNOWN:
+ default:
+ return false;
+ }
+ }
+
/**
* Parses a SSA V4+ color expression.
*
@@ -257,31 +319,37 @@ import java.util.regex.Pattern;
public final int nameIndex;
public final int alignmentIndex;
public final int primaryColorIndex;
+ public final int outlineColorIndex;
public final int fontSizeIndex;
public final int boldIndex;
public final int italicIndex;
public final int underlineIndex;
public final int strikeoutIndex;
+ public final int borderStyleIndex;
public final int length;
private Format(
int nameIndex,
int alignmentIndex,
int primaryColorIndex,
+ int outlineColorIndex,
int fontSizeIndex,
int boldIndex,
int italicIndex,
int underlineIndex,
int strikeoutIndex,
+ int borderStyleIndex,
int length) {
this.nameIndex = nameIndex;
this.alignmentIndex = alignmentIndex;
this.primaryColorIndex = primaryColorIndex;
+ this.outlineColorIndex = outlineColorIndex;
this.fontSizeIndex = fontSizeIndex;
this.boldIndex = boldIndex;
this.italicIndex = italicIndex;
this.underlineIndex = underlineIndex;
this.strikeoutIndex = strikeoutIndex;
+ this.borderStyleIndex = borderStyleIndex;
this.length = length;
}
@@ -295,11 +363,13 @@ import java.util.regex.Pattern;
int nameIndex = C.INDEX_UNSET;
int alignmentIndex = C.INDEX_UNSET;
int primaryColorIndex = C.INDEX_UNSET;
+ int outlineColorIndex = C.INDEX_UNSET;
int fontSizeIndex = C.INDEX_UNSET;
int boldIndex = C.INDEX_UNSET;
int italicIndex = C.INDEX_UNSET;
int underlineIndex = C.INDEX_UNSET;
int strikeoutIndex = C.INDEX_UNSET;
+ int borderStyleIndex = C.INDEX_UNSET;
String[] keys =
TextUtils.split(styleFormatLine.substring(SsaDecoder.FORMAT_LINE_PREFIX.length()), ",");
for (int i = 0; i < keys.length; i++) {
@@ -313,6 +383,9 @@ import java.util.regex.Pattern;
case "primarycolour":
primaryColorIndex = i;
break;
+ case "outlinecolour":
+ outlineColorIndex = i;
+ break;
case "fontsize":
fontSizeIndex = i;
break;
@@ -328,6 +401,9 @@ import java.util.regex.Pattern;
case "strikeout":
strikeoutIndex = i;
break;
+ case "borderstyle":
+ borderStyleIndex = i;
+ break;
}
}
return nameIndex != C.INDEX_UNSET
@@ -335,11 +411,13 @@ import java.util.regex.Pattern;
nameIndex,
alignmentIndex,
primaryColorIndex,
+ outlineColorIndex,
fontSizeIndex,
boldIndex,
italicIndex,
underlineIndex,
strikeoutIndex,
+ borderStyleIndex,
keys.length)
: null;
}
diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/ssa/SsaDecoderTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/ssa/SsaDecoderTest.java
index 00dbd36d07..0c9cfff208 100644
--- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/ssa/SsaDecoderTest.java
+++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/ssa/SsaDecoderTest.java
@@ -48,7 +48,8 @@ public final class SsaDecoderTest {
private static final String INVALID_TIMECODES = "media/ssa/invalid_timecodes";
private static final String INVALID_POSITIONS = "media/ssa/invalid_positioning";
private static final String POSITIONS_WITHOUT_PLAYRES = "media/ssa/positioning_without_playres";
- private static final String STYLE_COLORS = "media/ssa/style_colors";
+ private static final String STYLE_PRIMARY_COLOR = "media/ssa/style_primary_color";
+ private static final String STYLE_OUTLINE_COLOR = "media/ssa/style_outline_color";
private static final String STYLE_FONT_SIZE = "media/ssa/style_font_size";
private static final String STYLE_BOLD_ITALIC = "media/ssa/style_bold_italic";
private static final String STYLE_UNDERLINE = "media/ssa/style_underline";
@@ -297,9 +298,10 @@ public final class SsaDecoderTest {
}
@Test
- public void decodeColors() throws IOException {
+ public void decodePrimaryColor() throws IOException {
SsaDecoder decoder = new SsaDecoder();
- byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_COLORS);
+ byte[] bytes =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_PRIMARY_COLOR);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
assertThat(subtitle.getEventTimeCount()).isEqualTo(14);
// &H000000FF (AABBGGRR) -> #FFFF0000 (AARRGGBB)
@@ -344,6 +346,26 @@ public final class SsaDecoderTest {
.hasNoForegroundColorSpanBetween(0, seventhCueText.length());
}
+ @Test
+ public void decodeOutlineColor() throws IOException {
+ SsaDecoder decoder = new SsaDecoder();
+ byte[] bytes =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_OUTLINE_COLOR);
+ Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
+ assertThat(subtitle.getEventTimeCount()).isEqualTo(4);
+ Spanned firstCueText =
+ (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))).text;
+ SpannedSubject.assertThat(firstCueText)
+ .hasBackgroundColorSpanBetween(0, firstCueText.length())
+ .withColor(Color.BLUE);
+
+ // OutlineColour should be treated as background only when BorderStyle=3
+ Spanned secondCueText =
+ (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2))).text;
+ SpannedSubject.assertThat(secondCueText)
+ .hasNoBackgroundColorSpanBetween(0, secondCueText.length());
+ }
+
@Test
public void decodeFontSize() throws IOException {
SsaDecoder decoder = new SsaDecoder();
diff --git a/libraries/test_data/src/test/assets/media/ssa/style_outline_color b/libraries/test_data/src/test/assets/media/ssa/style_outline_color
new file mode 100644
index 0000000000..a16c2fb872
--- /dev/null
+++ b/libraries/test_data/src/test/assets/media/ssa/style_outline_color
@@ -0,0 +1,16 @@
+[Script Info]
+Title: OutlineColour settings
+Script Type: V4.00+
+PlayResX: 1280
+PlayResY: 720
+
+[V4+ Styles]
+Format: Name ,OutlineColour,BorderStyle
+Style: OutlineColourStyleBlue ,&H00FF0000 ,3
+Style: OutlineColourStyleIgnored ,&H00FF0000 ,1
+
+
+[Events]
+Format: Start ,End ,Style ,Text
+Dialogue: 0:00:01.00,0:00:02.00,OutlineColourStyleBlue ,Line with BLUE (&H00FF0000) outline.
+Dialogue: 0:00:03.00,0:00:04.00,OutlineColourStyleIgnored ,Line with ignored outline because BorderStyle is not 3.
diff --git a/libraries/test_data/src/test/assets/media/ssa/style_colors b/libraries/test_data/src/test/assets/media/ssa/style_primary_color
similarity index 97%
rename from libraries/test_data/src/test/assets/media/ssa/style_colors
rename to libraries/test_data/src/test/assets/media/ssa/style_primary_color
index a224e7ed4d..aa1e4ca7ca 100644
--- a/libraries/test_data/src/test/assets/media/ssa/style_colors
+++ b/libraries/test_data/src/test/assets/media/ssa/style_primary_color
@@ -1,5 +1,5 @@
[Script Info]
-Title: Coloring
+Title: PrimaryColour settings
Script Type: V4.00+
PlayResX: 1280
PlayResY: 720