diff --git a/libraries/ui/src/main/java/androidx/media3/ui/BidiUtils.java b/libraries/ui/src/main/java/androidx/media3/ui/BidiUtils.java new file mode 100644 index 00000000000..f530a2f4b8d --- /dev/null +++ b/libraries/ui/src/main/java/androidx/media3/ui/BidiUtils.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package androidx.media3.ui; + +import android.text.BidiFormatter; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextDirectionHeuristics; +import androidx.annotation.Nullable; +import androidx.media3.common.util.Log; +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Utility class for handling bidirectional (BiDi) text rendering. + *

+ * This class provides methods to check for right-to-left (RTL) characters in a text and to wrap + * text lines for proper BiDi rendering using {@link BidiFormatter}. + */ +final class BidiUtils { + + private static final String TAG = "BidiUtils"; + + /** + * Checks whether the given {@link CharSequence} contains any characters + * with right-to-left (RTL) directionality. + *

+ * This method inspects each character's Unicode directionality and returns {@code true} + * if at least one character is classified as RTL. This includes characters with the following + * directionalities: + *

+ * + * @param input the input {@link CharSequence} to analyze + * @return {@code true} if the input contains at least one RTL character; {@code false} otherwise + */ + static boolean containsRTL(@Nullable CharSequence input) { + if (input == null) { + return false; + } + int length = input.length(); + for (int offset = 0; offset < length; ) { + int codePoint = Character.codePointAt(input, offset); + byte dir = Character.getDirectionality(codePoint); + if (dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT || + dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC || + dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING || + dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE) { + return true; + } + offset += Character.charCount(codePoint); + } + return false; + } + + /** + * Applies bidirectional (BiDi) Unicode wrapping to each line of the given {@link CharSequence}. + *

+ * This method ensures that text containing both left-to-right (LTR) and right-to-left (RTL) + * scripts is displayed correctly by wrapping each line using {@link BidiFormatter#unicodeWrap}. + * It forces LTR context for wrapping and preserves spans and line breaks. + * + * @param input the input text as a {@link CharSequence}, possibly containing mixed-direction text + * @return a {@link CharSequence} with each line wrapped for proper bidi rendering + */ + public static CharSequence wrapText(CharSequence input) { + BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + Spanned spannedInput = null; + Object[] spans = null; + int[] spanStarts = null; + int[] spanEnds = null; + + + if (input instanceof Spanned) { + // Preserve span in the input text. + spannedInput = (Spanned) input; + spans = spannedInput.getSpans(0, input.length(), Object.class); + // Create arrays to track the start and end of each span after wrapping. + spanStarts = new int[spans.length]; + spanEnds = new int[spans.length]; + Arrays.fill(spanStarts, -1); + Arrays.fill(spanEnds, -1); + } + + // Determine the eol sequence for splitting the input text. + String inputStr = input.toString(); + String eol = "\n"; + if (inputStr.contains("\r\n")) { + eol = "\r\n"; + } + Iterable lines = Splitter.on(eol).split(inputStr); + + List wrappedLines = new ArrayList<>(); + + // Calculate the offset of each span after wrapping + int spanUpdate = 0; + int lineStart = 0; + for (String line : lines) { + // According to unicodeWrap documentation, this will either add 2 more characters or none + String wrappedLine = bidiFormatter.unicodeWrap(line, TextDirectionHeuristics.LTR, true); + if (spans != null) { + int diff = wrappedLine.length() - line.length(); + if (diff > 0) { + spanUpdate++; + } + for (int j = 0; j < spans.length; j++) { + // Each span start or end is updated only once + if ((spanStarts[j] < 0) && + (spannedInput.getSpanStart(spans[j]) >= lineStart) && + (spannedInput.getSpanStart(spans[j]) < lineStart + line.length())) { + spanStarts[j] = spanUpdate; + } + if ((spanEnds[j] < 0) && + ((spannedInput.getSpanEnd(spans[j]) - 1) >= lineStart) && + ((spannedInput.getSpanEnd(spans[j]) - 1) < lineStart + line.length())) { + spanEnds[j] = spanUpdate; + } + } + lineStart += line.length() + eol.length(); + if (diff > 0) { + spanUpdate++; + } + } + wrappedLines.add(wrappedLine); + } + + // Create a new SpannableStringBuilder with the wrapped lines. + Joiner joiner = Joiner.on("\n"); + SpannableStringBuilder wrapped = new SpannableStringBuilder(joiner.join(wrappedLines)); + + if (spans != null) { + // Reapply original spans to the wrapped lines. + for (int i = 0; i < spans.length; i++) { + int start = spannedInput.getSpanStart(spans[i]) + spanStarts[i]; + int end = spannedInput.getSpanEnd(spans[i]) + spanEnds[i]; + int flags = spannedInput.getSpanFlags(spans[i]); + if ((start >= 0) && (start < wrapped.length()) + && (end >= 0) && (end <= wrapped.length())) { + // Only set the span if the start and end are within bounds of the wrapped text. + wrapped.setSpan(spans[i], start, end, flags); + } else { + Log.w(TAG, + "Span out of bounds: start=" + start + ",end=" + end + ",len=" + wrapped.length()); + } + } + } + + return wrapped; + } +} diff --git a/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java b/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java index 4d298da946d..6c54289f6a6 100644 --- a/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java +++ b/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java @@ -190,7 +190,7 @@ public void draw( return; } - this.cueText = cue.text; + this.cueText = BidiUtils.containsRTL(cue.text) ? BidiUtils.wrapText(cue.text) : cue.text; this.cueTextAlignment = cue.textAlignment; this.cueBitmap = cue.bitmap; this.cueLine = cue.line; diff --git a/libraries/ui/src/test/java/androidx/media3/ui/BidiUtilsTest.java b/libraries/ui/src/test/java/androidx/media3/ui/BidiUtilsTest.java new file mode 100644 index 00000000000..5ba2cee3b53 --- /dev/null +++ b/libraries/ui/src/test/java/androidx/media3/ui/BidiUtilsTest.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package androidx.media3.ui; + +import static org.junit.Assert.*; +import org.junit.Test; +import org.robolectric.RobolectricTestRunner; +import org.junit.runner.RunWith; + +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.StyleSpan; + +/** Tests for {@link BidiUtils}. */ +@RunWith(RobolectricTestRunner.class) +public class BidiUtilsTest { + + @Test + public void containsRTL_nullInput_returnsFalse() { + assertFalse(BidiUtils.containsRTL(null)); + } + + @Test + public void containsRTL_emptyString_returnsFalse() { + assertFalse(BidiUtils.containsRTL("")); + } + + @Test + public void containsRTL_ltrOnly_returnsFalse() { + assertFalse(BidiUtils.containsRTL("Hello, world!")); + } + + @Test + public void containsRTL_rtlOnly_returnsTrue() { + // Hebrew "שלום" + assertTrue(BidiUtils.containsRTL("שלום")); + } + + @Test + public void containsRTL_mixedText_returnsTrue() { + // Mixed English and Arabic + assertTrue(BidiUtils.containsRTL("Hello مرحبا")); + } + + @Test + public void wrapText_plainText_wrapsEachLineWithUnicodeWrap() { + String input = "להתראות.\nשלום\nשלום!"; + CharSequence wrapped = BidiUtils.wrapText(input); + + String[] lines = wrapped.toString().split("\n"); + + assertEquals(3, lines.length); + assertTrue(lines[0].contains("להתראות.")); + assertTrue(lines[1].contains("שלום")); + assertTrue(lines[2].contains("שלום!")); + + // Ensure wrapping occurred (Unicode control characters are added) + assertTrue(lines[0].length() > "להתראות.".length() + || lines[1].length() > "שלום".length() + || lines[2].length() > "שלום!".length()); + } + + @Test + public void wrapText_plainText_wrapsEachLineWithUnicodeWrap_CRLF() { + String input = "נסיון" + "\r\n" + "בחלונות"; + CharSequence wrapped = BidiUtils.wrapText(input); + + String[] lines = wrapped.toString().split("\n"); + + assertEquals(2, lines.length); + assertTrue(lines[0].contains("נסיון")); + assertTrue(lines[1].contains("בחלונות")); + + // Ensure wrapping occurred (Unicode control characters are added) + assertTrue(lines[0].length() > "נסיון".length() + || lines[1].length() > "בחלונות".length()); + } + + @Test + public void wrapText_spansArePreserved() { + SpannableStringBuilder builder = new SpannableStringBuilder("שלום\nעולם"); + StyleSpan boldSpan = new StyleSpan(android.graphics.Typeface.BOLD); + builder.setSpan(boldSpan, 0, 7, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + CharSequence wrapped = BidiUtils.wrapText(builder); + + assertTrue(wrapped instanceof Spanned); + Spanned spanned = (Spanned) wrapped; + + int start = spanned.getSpanStart(boldSpan); + int end = spanned.getSpanEnd(boldSpan); + + assertTrue(start >= 0 && end > start); + } +} \ No newline at end of file