diff --git a/library/core/src/androidTest/assets/tx3g/no_subtitle b/library/core/src/androidTest/assets/tx3g/no_subtitle new file mode 100644 index 0000000000..09f370e38f Binary files /dev/null and b/library/core/src/androidTest/assets/tx3g/no_subtitle differ diff --git a/library/core/src/androidTest/assets/tx3g/sample_utf16_be_no_styl b/library/core/src/androidTest/assets/tx3g/sample_utf16_be_no_styl new file mode 100644 index 0000000000..9c3fed2b7d Binary files /dev/null and b/library/core/src/androidTest/assets/tx3g/sample_utf16_be_no_styl differ diff --git a/library/core/src/androidTest/assets/tx3g/sample_utf16_le_no_styl b/library/core/src/androidTest/assets/tx3g/sample_utf16_le_no_styl new file mode 100644 index 0000000000..03a2cb2350 Binary files /dev/null and b/library/core/src/androidTest/assets/tx3g/sample_utf16_le_no_styl differ diff --git a/library/core/src/androidTest/assets/tx3g/sample_with_multiple_styl b/library/core/src/androidTest/assets/tx3g/sample_with_multiple_styl new file mode 100644 index 0000000000..48b01c3438 Binary files /dev/null and b/library/core/src/androidTest/assets/tx3g/sample_with_multiple_styl differ diff --git a/library/core/src/androidTest/assets/tx3g/sample_with_other_extension b/library/core/src/androidTest/assets/tx3g/sample_with_other_extension new file mode 100644 index 0000000000..e2eb7ac002 Binary files /dev/null and b/library/core/src/androidTest/assets/tx3g/sample_with_other_extension differ diff --git a/library/core/src/androidTest/assets/tx3g/sample_with_styl b/library/core/src/androidTest/assets/tx3g/sample_with_styl new file mode 100644 index 0000000000..b0f0d54265 Binary files /dev/null and b/library/core/src/androidTest/assets/tx3g/sample_with_styl differ diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java new file mode 100644 index 0000000000..ce5281584a --- /dev/null +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.text.tx3g; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.test.InstrumentationTestCase; +import android.text.SpannedString; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.text.Subtitle; +import com.google.android.exoplayer2.text.SubtitleDecoderException; +import java.io.IOException; + +/** + * Unit test for {@link Tx3gDecoder}. + */ +public final class Tx3gDecoderTest extends InstrumentationTestCase { + + private static final String NO_SUBTITLE = "tx3g/no_subtitle"; + private static final String SAMPLE_WITH_STYL = "tx3g/sample_with_styl"; + private static final String SAMPLE_UTF16_BE_NO_STYL = "tx3g/sample_utf16_be_no_styl"; + private static final String SAMPLE_UTF16_LE_NO_STYL = "tx3g/sample_utf16_le_no_styl"; + private static final String SAMPLE_WITH_MULTIPLE_STYL = "tx3g/sample_with_multiple_styl"; + private static final String SAMPLE_WITH_OTHER_EXTENSION = "tx3g/sample_with_other_extension"; + + public void testDecodeNoSubtitle() throws IOException, SubtitleDecoderException { + Tx3gDecoder decoder = new Tx3gDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), NO_SUBTITLE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertTrue(subtitle.getCues(0).isEmpty()); + } + + public void testDecodeWithStyl() throws IOException, SubtitleDecoderException { + Tx3gDecoder decoder = new Tx3gDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), SAMPLE_WITH_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); + assertEquals("CC Test", text.toString()); + assertEquals(3, text.getSpans(0, text.length(), Object.class).length); + StyleSpan styleSpan = findSpan(text, 0, 6, StyleSpan.class); + assertEquals(Typeface.BOLD_ITALIC, styleSpan.getStyle()); + findSpan(text, 0, 6, UnderlineSpan.class); + ForegroundColorSpan colorSpan = findSpan(text, 0, 6, ForegroundColorSpan.class); + assertEquals(Color.GREEN, colorSpan.getForegroundColor()); + } + + public void testDecodeUtf16BeNoStyl() throws IOException, SubtitleDecoderException { + Tx3gDecoder decoder = new Tx3gDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), SAMPLE_UTF16_BE_NO_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); + assertEquals("你好", text.toString()); + assertEquals(0, text.getSpans(0, text.length(), Object.class).length); + } + + public void testDecodeUtf16LeNoStyl() throws IOException, SubtitleDecoderException { + Tx3gDecoder decoder = new Tx3gDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), SAMPLE_UTF16_LE_NO_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); + assertEquals("你好", text.toString()); + assertEquals(0, text.getSpans(0, text.length(), Object.class).length); + } + + public void testDecodeWithMultipleStyl() throws IOException, SubtitleDecoderException { + Tx3gDecoder decoder = new Tx3gDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), SAMPLE_WITH_MULTIPLE_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); + assertEquals("Line 2\nLine 3", text.toString()); + assertEquals(4, text.getSpans(0, text.length(), Object.class).length); + StyleSpan styleSpan = findSpan(text, 0, 5, StyleSpan.class); + assertEquals(Typeface.ITALIC, styleSpan.getStyle()); + findSpan(text, 7, 12, UnderlineSpan.class); + ForegroundColorSpan colorSpan = findSpan(text, 0, 5, ForegroundColorSpan.class); + assertEquals(Color.GREEN, colorSpan.getForegroundColor()); + colorSpan = findSpan(text, 7, 12, ForegroundColorSpan.class); + assertEquals(Color.GREEN, colorSpan.getForegroundColor()); + } + + public void testDecodeWithOtherExtension() throws IOException, SubtitleDecoderException { + Tx3gDecoder decoder = new Tx3gDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), SAMPLE_WITH_OTHER_EXTENSION); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); + assertEquals("CC Test", text.toString()); + assertEquals(2, text.getSpans(0, text.length(), Object.class).length); + StyleSpan styleSpan = findSpan(text, 0, 6, StyleSpan.class); + assertEquals(Typeface.BOLD, styleSpan.getStyle()); + ForegroundColorSpan colorSpan = findSpan(text, 0, 6, ForegroundColorSpan.class); + assertEquals(Color.GREEN, colorSpan.getForegroundColor()); + } + + private static T findSpan(SpannedString testObject, int expectedStart, int expectedEnd, + Class expectedType) { + T[] spans = testObject.getSpans(0, testObject.length(), expectedType); + for (T span : spans) { + if (testObject.getSpanStart(span) == expectedStart + && testObject.getSpanEnd(span) == expectedEnd) { + return span; + } + } + fail("Span not found."); + return null; + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/C.java b/library/core/src/main/java/com/google/android/exoplayer2/C.java index 02e5939b86..56092f17a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/C.java @@ -76,6 +76,11 @@ public final class C { */ public static final String UTF8_NAME = "UTF-8"; + /** + * The name of the UTF-16 charset. + */ + public static final String UTF16_NAME = "UTF-16"; + /** * Crypto modes for a codec. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java index dccb64caec..79ec89838a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java @@ -15,10 +15,21 @@ */ package com.google.android.exoplayer2.text.tx3g; +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.SimpleSubtitleDecoder; import com.google.android.exoplayer2.text.Subtitle; +import com.google.android.exoplayer2.text.SubtitleDecoderException; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; +import java.nio.charset.Charset; /** * A {@link SimpleSubtitleDecoder} for tx3g. @@ -27,6 +38,20 @@ import com.google.android.exoplayer2.util.ParsableByteArray; */ public final class Tx3gDecoder extends SimpleSubtitleDecoder { + private static final char BOM_UTF16_BE = '\uFEFF'; + private static final char BOM_UTF16_LE = '\uFFFE'; + + private static final int TYPE_STYL = Util.getIntegerCodeForString("styl"); + + private static final int SIZE_ATOM_HEADER = 8; + private static final int SIZE_SHORT = 2; + private static final int SIZE_BOM_UTF16 = 2; + private static final int SIZE_STYLE_RECORD = 12; + + private static final int FONT_FACE_BOLD = 0x0001; + private static final int FONT_FACE_ITALIC = 0x0002; + private static final int FONT_FACE_UNDERLINE = 0x0004; + private final ParsableByteArray parsableByteArray; public Tx3gDecoder() { @@ -35,14 +60,90 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { } @Override - protected Subtitle decode(byte[] bytes, int length, boolean reset) { - parsableByteArray.reset(bytes, length); - int textLength = parsableByteArray.readUnsignedShort(); - if (textLength == 0) { - return Tx3gSubtitle.EMPTY; + protected Subtitle decode(byte[] bytes, int length, boolean reset) + throws SubtitleDecoderException { + try { + parsableByteArray.reset(bytes, length); + String cueTextString = readSubtitleText(parsableByteArray); + if (cueTextString.isEmpty()) { + return Tx3gSubtitle.EMPTY; + } + SpannableStringBuilder cueText = new SpannableStringBuilder(cueTextString); + while (parsableByteArray.bytesLeft() >= SIZE_ATOM_HEADER) { + int atomSize = parsableByteArray.readInt(); + int atomType = parsableByteArray.readInt(); + if (atomType == TYPE_STYL) { + Assertions.checkArgument(parsableByteArray.bytesLeft() >= SIZE_SHORT); + int styleRecordCount = parsableByteArray.readUnsignedShort(); + for (int i = 0; i < styleRecordCount; i++) { + applyStyleRecord(parsableByteArray, cueText); + } + } else { + parsableByteArray.skipBytes(atomSize - SIZE_ATOM_HEADER); + } + } + return new Tx3gSubtitle(new Cue(cueText)); + } catch (IllegalArgumentException e) { + throw new SubtitleDecoderException("Unexpected subtitle format.", e); } - String cueText = parsableByteArray.readString(textLength); - return new Tx3gSubtitle(new Cue(cueText)); } + private static String readSubtitleText(ParsableByteArray parsableByteArray) { + Assertions.checkArgument(parsableByteArray.bytesLeft() >= SIZE_SHORT); + int textLength = parsableByteArray.readUnsignedShort(); + if (textLength == 0) { + return ""; + } + if (parsableByteArray.bytesLeft() >= SIZE_BOM_UTF16) { + char firstChar = parsableByteArray.peekChar(); + if (firstChar == BOM_UTF16_BE || firstChar == BOM_UTF16_LE) { + return parsableByteArray.readString(textLength, Charset.forName(C.UTF16_NAME)); + } + } + return parsableByteArray.readString(textLength, Charset.forName(C.UTF8_NAME)); + } + + private static void applyStyleRecord(ParsableByteArray parsableByteArray, + SpannableStringBuilder cueText) { + Assertions.checkArgument(parsableByteArray.bytesLeft() >= SIZE_STYLE_RECORD); + int start = parsableByteArray.readUnsignedShort(); + int end = parsableByteArray.readUnsignedShort(); + parsableByteArray.skipBytes(2); // font identifier + int fontFace = parsableByteArray.readUnsignedByte(); + parsableByteArray.skipBytes(1); // font size + int colorRgba = parsableByteArray.readInt(); + + if (fontFace != 0) { + attachFontFace(cueText, fontFace, start, end); + } + attachColor(cueText, colorRgba, start, end); + } + + private static void attachFontFace(SpannableStringBuilder cueText, int fontFace, int start, + int end) { + boolean isBold = (fontFace & FONT_FACE_BOLD) != 0; + boolean isItalic = (fontFace & FONT_FACE_ITALIC) != 0; + if (isBold) { + if (isItalic) { + cueText.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } else { + cueText.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } else if (isItalic) { + cueText.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + boolean isUnderlined = (fontFace & FONT_FACE_UNDERLINE) != 0; + if (isUnderlined) { + cueText.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + private static void attachColor(SpannableStringBuilder cueText, int colorRgba, int start, + int end) { + int colorArgb = ((colorRgba & 0xFF) << 24) | (colorRgba >>> 8); + cueText.setSpan(new ForegroundColorSpan(colorArgb), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index ef4aa05cfe..2a907e5955 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -201,6 +201,14 @@ public final class ParsableByteArray { return (data[position] & 0xFF); } + /** + * Peeks at the next char. + */ + public char peekChar() { + return (char) ((data[position] & 0xFF) << 8 + | (data[position + 1] & 0xFF)); + } + /** * Reads the next byte as an unsigned value. */