Fix TTML handling of inherited percentage tts:fontSize values

The percentage should be interpreted as relative to the size of a parent
node.

This change makes this inheritance work correctly for percentages in
both the parent and child. It does not fix the case of a non-percentage
parent size with a percentage child size.

PiperOrigin-RevId: 649631055
This commit is contained in:
ibaker 2024-07-05 05:54:46 -07:00 committed by Copybara-Service
parent 8f72054f2b
commit bb2fd002ae
8 changed files with 187 additions and 39 deletions

View File

@ -14,6 +14,9 @@
* Audio:
* Video:
* Text:
* TTML: Fix handling of percentage `tts:fontSize` values to ensure they
are correctly inherited from parent nodes with percentage `tts:fontSize`
values.
* Metadata:
* Image:
* DataSource:

View File

@ -17,6 +17,7 @@ package androidx.media3.common.text;
import android.text.Spannable;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import androidx.media3.common.util.UnstableApi;
/**
@ -44,14 +45,52 @@ public final class SpanUtil {
Spannable spannable, Object span, int start, int end, int spanFlags) {
Object[] existingSpans = spannable.getSpans(start, end, span.getClass());
for (Object existingSpan : existingSpans) {
if (spannable.getSpanStart(existingSpan) == start
&& spannable.getSpanEnd(existingSpan) == end
&& spannable.getSpanFlags(existingSpan) == spanFlags) {
spannable.removeSpan(existingSpan);
}
removeIfStartEndAndFlagsMatch(spannable, existingSpan, start, end, spanFlags);
}
spannable.setSpan(span, start, end, spanFlags);
}
/**
* Modifies the size of the text between {@code start} and {@code end} relative to any existing
* {@link RelativeSizeSpan} instances which cover <b>at least the same range</b>.
*
* <p>{@link RelativeSizeSpan} instances which only cover a part of the text between {@code start}
* and {@code end} are ignored.
*
* <p>A new {@link RelativeSizeSpan} instance is added between {@code start} and {@code end} with
* its {@code sizeChange} value computed by modifying the {@code size} parameter by the {@code
* sizeChange} of {@link RelativeSizeSpan} instances covering between {@code start} and {@code
* end}.
*
* <p>{@link RelativeSizeSpan} instances with the same {@code start}, {@code end}, and {@code
* spanFlags} are removed.
*
* @param spannable The {@link Spannable} to add the {@link RelativeSizeSpan} to.
* @param size The fraction to modify the text size by.
* @param start The start index to add the new span at.
* @param end The end index to add the new span at.
* @param spanFlags The flags to pass to {@link Spannable#setSpan(Object, int, int, int)}.
*/
public static void addInheritedRelativeSizeSpan(
Spannable spannable, float size, int start, int end, int spanFlags) {
for (RelativeSizeSpan existingSpan : spannable.getSpans(start, end, RelativeSizeSpan.class)) {
if (spannable.getSpanStart(existingSpan) <= start
&& spannable.getSpanEnd(existingSpan) >= end) {
size *= existingSpan.getSizeChange();
}
removeIfStartEndAndFlagsMatch(spannable, existingSpan, start, end, spanFlags);
}
spannable.setSpan(new RelativeSizeSpan(size), start, end, spanFlags);
}
private static void removeIfStartEndAndFlagsMatch(
Spannable spannable, Object span, int start, int end, int spanFlags) {
if (spannable.getSpanStart(span) == start
&& spannable.getSpanEnd(span) == end
&& spannable.getSpanFlags(span) == spanFlags) {
spannable.removeSpan(span);
}
}
private SpanUtil() {}
}

View File

@ -15,6 +15,7 @@
*/
package androidx.media3.common.text;
import static androidx.media3.test.utils.truth.SpannedSubject.assertThat;
import static com.google.common.truth.Truth.assertThat;
import android.graphics.Color;
@ -23,6 +24,7 @@ import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@ -83,4 +85,76 @@ public class SpanUtilTest {
.containsExactly(originalSpan, differentStart, differentEnd, differentFlags)
.inOrder();
}
@Test
public void addInheritedRelativeSizeSpan_noExistingSpans() {
Spannable spannable = SpannableString.valueOf("test text");
SpanUtil.addInheritedRelativeSizeSpan(
spannable,
/* size= */ 0.5f,
/* start= */ 2,
/* end= */ 5,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
assertThat(spannable).hasRelativeSizeSpanBetween(2, 5).withSizeChange(0.5f);
}
@Test
public void addInheritedRelativeSizeSpan_existingSpanWithSameRange_replaced() {
Spannable spannable = SpannableString.valueOf("test text");
spannable.setSpan(
new RelativeSizeSpan(1.6f), /* start= */ 2, /* end= */ 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
SpanUtil.addInheritedRelativeSizeSpan(
spannable,
/* size= */ 0.5f,
/* start= */ 2,
/* end= */ 5,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
RelativeSizeSpan[] spans = spannable.getSpans(2, 5, RelativeSizeSpan.class);
assertThat(spans).hasLength(1);
assertThat(spans[0].getSizeChange()).isWithin(0.0000001f).of(0.8f);
}
@Test
public void addInheritedRelativeSizeSpan_existingLongerSpan() {
Spannable spannable = SpannableString.valueOf("test text");
spannable.setSpan(
new RelativeSizeSpan(1.6f), /* start= */ 1, /* end= */ 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
SpanUtil.addInheritedRelativeSizeSpan(
spannable,
/* size= */ 0.5f,
/* start= */ 2,
/* end= */ 5,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
RelativeSizeSpan[] spans = spannable.getSpans(2, 5, RelativeSizeSpan.class);
assertThat(spans).hasLength(2);
assertThat(spannable).hasRelativeSizeSpanBetween(2, 5).withSizeChange(0.8f);
}
@Test
public void addInheritedRelativeSizeSpan_existingIncompleteSpans_ignored() {
Spannable spannable = SpannableString.valueOf("test text");
spannable.setSpan(
new RelativeSizeSpan(2.3f), /* start= */ 1, /* end= */ 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(
new RelativeSizeSpan(1.6f), /* start= */ 3, /* end= */ 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(
new RelativeSizeSpan(2.3f), /* start= */ 3, /* end= */ 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
SpanUtil.addInheritedRelativeSizeSpan(
spannable,
/* size= */ 0.5f,
/* start= */ 2,
/* end= */ 5,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
RelativeSizeSpan[] spans = spannable.getSpans(2, 5, RelativeSizeSpan.class);
assertThat(spans).hasLength(4);
assertThat(spannable).hasRelativeSizeSpanBetween(2, 5).withSizeChange(0.5f);
}
}

View File

@ -28,6 +28,7 @@ import androidx.media3.common.util.ColorParser;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.ttml.TtmlParser;
import androidx.media3.test.utils.TestUtil;
import androidx.media3.test.utils.truth.SpannedSubject;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
@ -149,10 +150,10 @@ public final class DelegatingSubtitleDecoderTtmlParserTest {
public void inheritGlobalStyleOverriddenByInlineAttributes() throws IOException {
Subtitle subtitle = getSubtitle(INHERIT_STYLE_OVERRIDE_TTML_FILE);
assertThat(subtitle.getEventTimeCount()).isEqualTo(4);
assertThat(subtitle.getEventTimeCount()).isEqualTo(6);
Spanned firstCueText = getOnlyCueTextAtTimeUs(subtitle, 10_000_000);
assertThat(firstCueText.toString()).isEqualTo("text 1");
assertThat(firstCueText.toString()).isEqualTo("default + s0 styles");
assertThat(firstCueText).hasTypefaceSpanBetween(0, firstCueText.length()).withFamily("serif");
assertThat(firstCueText).hasBoldItalicSpanBetween(0, firstCueText.length());
assertThat(firstCueText).hasUnderlineSpanBetween(0, firstCueText.length());
@ -162,9 +163,12 @@ public final class DelegatingSubtitleDecoderTtmlParserTest {
assertThat(firstCueText)
.hasForegroundColorSpanBetween(0, firstCueText.length())
.withColor(0xFFFFFF00);
SpannedSubject.assertThat(firstCueText)
.hasRelativeSizeSpanBetween(0, firstCueText.length())
.withSizeChange(1.5f);
Spanned secondCueText = getOnlyCueTextAtTimeUs(subtitle, 20_000_000);
assertThat(secondCueText.toString()).isEqualTo("text 2");
assertThat(secondCueText.toString()).isEqualTo("default + s0 + overrides");
assertThat(secondCueText)
.hasTypefaceSpanBetween(0, secondCueText.length())
.withFamily("sansSerif");
@ -176,6 +180,15 @@ public final class DelegatingSubtitleDecoderTtmlParserTest {
assertThat(secondCueText)
.hasForegroundColorSpanBetween(0, secondCueText.length())
.withColor(0xFFFFFF00);
SpannedSubject.assertThat(secondCueText)
.hasRelativeSizeSpanBetween(0, secondCueText.length())
.withSizeChange(0.9f);
Spanned thirdCueText = getOnlyCueTextAtTimeUs(subtitle, 30_000_000);
assertThat(thirdCueText.toString()).isEqualTo("default styling only");
assertThat(thirdCueText)
.hasRelativeSizeSpanBetween(0, thirdCueText.length())
.withSizeChange(0.75f);
}
@Test

View File

@ -239,12 +239,8 @@ import java.util.Map;
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
break;
case TtmlStyle.FONT_SIZE_UNIT_PERCENT:
SpanUtil.addOrReplaceSpan(
builder,
new RelativeSizeSpan(style.getFontSize() / 100),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
SpanUtil.addInheritedRelativeSizeSpan(
builder, style.getFontSize() / 100, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
break;
case TtmlStyle.UNSPECIFIED:
// Do nothing.

View File

@ -325,10 +325,10 @@ public final class TtmlParserTest {
public void inheritGlobalStyleOverriddenByInlineAttributes() throws Exception {
ImmutableList<CuesWithTiming> allCues = getAllCues(INHERIT_STYLE_OVERRIDE_TTML_FILE);
assertThat(allCues).hasSize(2);
assertThat(allCues).hasSize(3);
Spanned firstCueText = getOnlyCueTextAtIndex(allCues, 0);
assertThat(firstCueText.toString()).isEqualTo("text 1");
assertThat(firstCueText.toString()).isEqualTo("default + s0 styles");
assertThat(firstCueText).hasTypefaceSpanBetween(0, firstCueText.length()).withFamily("serif");
assertThat(firstCueText).hasBoldItalicSpanBetween(0, firstCueText.length());
assertThat(firstCueText).hasUnderlineSpanBetween(0, firstCueText.length());
@ -338,9 +338,12 @@ public final class TtmlParserTest {
assertThat(firstCueText)
.hasForegroundColorSpanBetween(0, firstCueText.length())
.withColor(0xFFFFFF00);
assertThat(firstCueText)
.hasRelativeSizeSpanBetween(0, firstCueText.length())
.withSizeChange(1.5f);
Spanned secondCueText = getOnlyCueTextAtIndex(allCues, 1);
assertThat(secondCueText.toString()).isEqualTo("text 2");
assertThat(secondCueText.toString()).isEqualTo("default + s0 + overrides");
assertThat(secondCueText)
.hasTypefaceSpanBetween(0, secondCueText.length())
.withFamily("sansSerif");
@ -352,6 +355,15 @@ public final class TtmlParserTest {
assertThat(secondCueText)
.hasForegroundColorSpanBetween(0, secondCueText.length())
.withColor(0xFFFFFF00);
assertThat(secondCueText)
.hasRelativeSizeSpanBetween(0, secondCueText.length())
.withSizeChange(0.9f);
Spanned thirdCueText = getOnlyCueTextAtIndex(allCues, 2);
assertThat(thirdCueText.toString()).isEqualTo("default styling only");
assertThat(thirdCueText)
.hasRelativeSizeSpanBetween(0, thirdCueText.length())
.withSizeChange(0.75f);
}
@Test

View File

@ -2,28 +2,35 @@
xmlns:ttp="http://www.w3.org/2006/10/ttaf1#parameter"
xmlns:tts="http://www.w3.org/2006/10/ttaf1#style"
xmlns:ttm="http://www.w3.org/2006/10/ttaf1#metadata">
<head>
<styling>
<style id="s0"
tts:fontWeight="bold"
tts:fontStyle="italic"
tts:fontFamily="serif"
tts:textDecoration="underline"
tts:backgroundColor="blue"
tts:color="yellow"/>
</styling>
</head>
<body>
<head>
<styling>
<style id="default-style"
tts:fontSize="75%"/>
<style id="s0"
tts:fontWeight="bold"
tts:fontStyle="italic"
tts:fontFamily="serif"
tts:textDecoration="underline"
tts:backgroundColor="blue"
tts:color="yellow"
tts:fontSize="200%"/>
</styling>
</head>
<body style="default-style">
<div>
<p style="s0" begin="10s" end="18s">default + s0 styles</p>
</div>
<div>
<p style="s0" begin="20s" end="28s"
tts:fontWeight="normal"
tts:fontFamily="sansSerif"
tts:backgroundColor="red"
tts:color="yellow"
tts:fontSize="120%"
>default + s0 + overrides</p>
</div>
<div>
<p style="s0" begin="10s" end="18s">text 1</p>
</div>
<div>
<p style="s0" begin="20s" end="28s"
tts:fontWeight="normal"
tts:fontFamily="sansSerif"
tts:backgroundColor="red"
tts:color="yellow"
>text 2</p>
<p begin="30s" end="38s">default styling only</p>
</div>
</body>
</tt>

View File

@ -17,6 +17,7 @@
package androidx.media3.test.utils.truth;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.truth.Correspondence.tolerance;
import static com.google.common.truth.Fact.fact;
import static com.google.common.truth.Fact.simpleFact;
import static com.google.common.truth.Truth.assertAbout;
@ -1070,7 +1071,10 @@ public final class SpannedSubject extends Subject {
}
}
check("sizeChange").that(spanSizes).containsExactly(size);
check("sizeChange")
.that(spanSizes)
.comparingElementsUsing(tolerance(0.0000001))
.containsExactly(size);
return check("flags").about(spanFlags()).that(matchingSpanFlags);
}
}