From a9c8f2f383d86cf0f9765b0a499d503215d7fd61 Mon Sep 17 00:00:00 2001 From: michaelkatz Date: Fri, 28 Apr 2023 16:31:11 +0100 Subject: [PATCH] Add UTF-16 encoded subtitle support to SsaDecoder Issue: androidx/media#319 PiperOrigin-RevId: 527891646 --- .../exoplayer2/util/ParsableByteArray.java | 58 +++++++++++++---- .../exoplayer2/text/ssa/SsaDecoder.java | 51 ++++++++++----- .../exoplayer2/text/ssa/SsaDecoderTest.java | 59 ++++++++++++++++++ .../src/test/assets/media/ssa/typical_utf16be | Bin 0 -> 1460 bytes .../src/test/assets/media/ssa/typical_utf16le | Bin 0 -> 1484 bytes 5 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 testdata/src/test/assets/media/ssa/typical_utf16be create mode 100644 testdata/src/test/assets/media/ssa/typical_utf16le diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index 29c2aa5153e..c12c5e17f2a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -232,11 +232,28 @@ public int peekUnsignedByte() { return (data[position] & 0xFF); } - /** Peeks at the next char. */ + /** + * Peeks at the next char. + * + *

Equivalent to passing {@link Charsets#UTF_16} or {@link Charsets#UTF_16BE} to {@link + * #peekChar(Charset)}. + */ public char peekChar() { return (char) ((data[position] & 0xFF) << 8 | (data[position + 1] & 0xFF)); } + /** + * Peeks at the next char (as decoded by {@code charset}) + * + * @throws IllegalArgumentException if charset is not supported. Only US_ASCII, UTF-8, UTF-16, + * UTF-16BE, and UTF-16LE are supported. + */ + public char peekChar(Charset charset) { + Assertions.checkArgument( + SUPPORTED_CHARSETS_FOR_READLINE.contains(charset), "Unsupported charset: " + charset); + return (char) (peekCharacterAndSize(charset) >> Short.SIZE); + } + /** Reads the next byte as an unsigned value. */ public int readUnsignedByte() { return (data[position++] & 0xFF); @@ -648,27 +665,42 @@ private void skipLineTerminator(Charset charset) { * UTF-8 and two bytes for UTF-16). */ private char readCharacterIfInList(Charset charset, char[] chars) { - char character; - int characterSize; + int characterAndSize = peekCharacterAndSize(charset); + + if (characterAndSize != 0 && Chars.contains(chars, (char) (characterAndSize >> Short.SIZE))) { + position += characterAndSize & 0xFFFF; + return (char) (characterAndSize >> Short.SIZE); + } else { + return 0; + } + } + + /** + * Peeks at the character at {@link #position} (as decoded by {@code charset}), returns it and the + * number of bytes the character takes up within the array packed into an int. First four bytes + * are the character and the second four is the size in bytes it takes. Returns 0 if {@link + * #bytesLeft()} doesn't allow reading a whole character in {@code charset} or if the {@code + * charset} is not one of US_ASCII, UTF-8, UTF-16, UTF-16BE, or UTF-16LE. + * + *

Only supports characters that occupy a single code unit (i.e. one byte for UTF-8 and two + * bytes for UTF-16). + */ + private int peekCharacterAndSize(Charset charset) { + byte character; + short characterSize; if ((charset.equals(Charsets.UTF_8) || charset.equals(Charsets.US_ASCII)) && bytesLeft() >= 1) { - character = Chars.checkedCast(UnsignedBytes.toInt(data[position])); + character = (byte) Chars.checkedCast(UnsignedBytes.toInt(data[position])); characterSize = 1; } else if ((charset.equals(Charsets.UTF_16) || charset.equals(Charsets.UTF_16BE)) && bytesLeft() >= 2) { - character = Chars.fromBytes(data[position], data[position + 1]); + character = (byte) Chars.fromBytes(data[position], data[position + 1]); characterSize = 2; } else if (charset.equals(Charsets.UTF_16LE) && bytesLeft() >= 2) { - character = Chars.fromBytes(data[position + 1], data[position]); + character = (byte) Chars.fromBytes(data[position + 1], data[position]); characterSize = 2; } else { return 0; } - - if (Chars.contains(chars, character)) { - position += characterSize; - return Chars.checkedCast(character); - } else { - return 0; - } + return (Chars.checkedCast(character) << Short.SIZE) + characterSize; } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/library/extractor/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java index 241300ea2d2..49ba8faf2a8 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -36,6 +36,8 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import com.google.common.base.Ascii; +import com.google.common.base.Charsets; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -96,11 +98,14 @@ public SsaDecoder(@Nullable List initializationData) { if (initializationData != null && !initializationData.isEmpty()) { haveInitializationData = true; + // Currently, construction with initialization data is only relevant to SSA subtitles muxed + // in a MKV. According to https://www.matroska.org/technical/subtitles.html, these muxed + // subtitles are always encoded in UTF-8. String formatLine = Util.fromUtf8Bytes(initializationData.get(0)); Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX)); dialogueFormatFromInitializationData = Assertions.checkNotNull(SsaDialogueFormat.fromFormatLine(formatLine)); - parseHeader(new ParsableByteArray(initializationData.get(1))); + parseHeader(new ParsableByteArray(initializationData.get(1)), Charsets.UTF_8); } else { haveInitializationData = false; dialogueFormatFromInitializationData = null; @@ -113,25 +118,37 @@ protected Subtitle decode(byte[] data, int length, boolean reset) { List cueTimesUs = new ArrayList<>(); ParsableByteArray parsableData = new ParsableByteArray(data, length); + Charset charset = detectUtfCharset(parsableData); + if (!haveInitializationData) { - parseHeader(parsableData); + parseHeader(parsableData, charset); } - parseEventBody(parsableData, cues, cueTimesUs); + parseEventBody(parsableData, cues, cueTimesUs, charset); return new SsaSubtitle(cues, cueTimesUs); } + /** + * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if + * no BOM is found. + */ + private Charset detectUtfCharset(ParsableByteArray data) { + @Nullable Charset charset = data.readUtfCharsetFromBom(); + return charset != null ? charset : Charsets.UTF_8; + } + /** * Parses the header of the subtitle. * * @param data A {@link ParsableByteArray} from which the header should be read. + * @param charset The {@code Charset} of the encoding of {@code data}. */ - private void parseHeader(ParsableByteArray data) { + private void parseHeader(ParsableByteArray data, Charset charset) { @Nullable String currentLine; - while ((currentLine = data.readLine()) != null) { + while ((currentLine = data.readLine(charset)) != null) { if ("[Script Info]".equalsIgnoreCase(currentLine)) { - parseScriptInfo(data); + parseScriptInfo(data, charset); } else if ("[V4+ Styles]".equalsIgnoreCase(currentLine)) { - styles = parseStyles(data); + styles = parseStyles(data, charset); } else if ("[V4 Styles]".equalsIgnoreCase(currentLine)) { Log.i(TAG, "[V4 Styles] are not supported"); } else if ("[Events]".equalsIgnoreCase(currentLine)) { @@ -149,11 +166,12 @@ private void parseHeader(ParsableByteArray data) { * * @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition() position} * set to the beginning of the first line after {@code [Script Info]}. + * @param charset The {@code Charset} of the encoding of {@code data}. */ - private void parseScriptInfo(ParsableByteArray data) { + private void parseScriptInfo(ParsableByteArray data, Charset charset) { @Nullable String currentLine; - while ((currentLine = data.readLine()) != null - && (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) { + while ((currentLine = data.readLine(charset)) != null + && (data.bytesLeft() == 0 || data.peekChar(charset) != '[')) { String[] infoNameAndValue = currentLine.split(":"); if (infoNameAndValue.length != 2) { continue; @@ -185,13 +203,14 @@ private void parseScriptInfo(ParsableByteArray data) { * * @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition()} pointing * at the beginning of the first line after {@code [V4+ Styles]}. + * @param charset The {@code Charset} of the encoding of {@code data}. */ - private static Map parseStyles(ParsableByteArray data) { + private static Map parseStyles(ParsableByteArray data, Charset charset) { Map styles = new LinkedHashMap<>(); @Nullable SsaStyle.Format formatInfo = null; @Nullable String currentLine; - while ((currentLine = data.readLine()) != null - && (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) { + while ((currentLine = data.readLine(charset)) != null + && (data.bytesLeft() == 0 || data.peekChar(charset) != '[')) { if (currentLine.startsWith(FORMAT_LINE_PREFIX)) { formatInfo = SsaStyle.Format.fromFormatLine(currentLine); } else if (currentLine.startsWith(STYLE_LINE_PREFIX)) { @@ -214,12 +233,14 @@ private static Map parseStyles(ParsableByteArray data) { * @param data A {@link ParsableByteArray} from which the body should be read. * @param cues A list to which parsed cues will be added. * @param cueTimesUs A sorted list to which parsed cue timestamps will be added. + * @param charset The {@code Charset} of the encoding of {@code data}. */ - private void parseEventBody(ParsableByteArray data, List> cues, List cueTimesUs) { + private void parseEventBody( + ParsableByteArray data, List> cues, List cueTimesUs, Charset charset) { @Nullable SsaDialogueFormat format = haveInitializationData ? dialogueFormatFromInitializationData : null; @Nullable String currentLine; - while ((currentLine = data.readLine()) != null) { + while ((currentLine = data.readLine(charset)) != null) { if (currentLine.startsWith(FORMAT_LINE_PREFIX)) { format = SsaDialogueFormat.fromFormatLine(currentLine); } else if (currentLine.startsWith(DIALOGUE_LINE_PREFIX)) { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java index 1e1ab6c80ba..6f11242d93a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java @@ -30,6 +30,7 @@ import com.google.common.collect.Iterables; import java.io.IOException; import java.util.ArrayList; +import java.util.Objects; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,6 +44,8 @@ public final class SsaDecoderTest { private static final String TYPICAL_HEADER_ONLY = "media/ssa/typical_header"; private static final String TYPICAL_DIALOGUE_ONLY = "media/ssa/typical_dialogue"; private static final String TYPICAL_FORMAT_ONLY = "media/ssa/typical_format"; + private static final String TYPICAL_UTF16LE = "media/ssa/typical_utf16le"; + private static final String TYPICAL_UTF16BE = "media/ssa/typical_utf16be"; private static final String OVERLAPPING_TIMECODES = "media/ssa/overlapping_timecodes"; private static final String POSITIONS = "media/ssa/positioning"; private static final String INVALID_TIMECODES = "media/ssa/invalid_timecodes"; @@ -130,6 +133,58 @@ public void decodeTypicalWithInitializationData() throws IOException { assertTypicalCue3(subtitle, 4); } + @Test + public void decodeTypicalUtf16le() throws IOException { + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(6); + // Check position, line, anchors & alignment are set from Alignment Style (2 - bottom-center). + Cue firstCue = subtitle.getCues(subtitle.getEventTime(0)).get(0); + assertWithMessage("Cue.textAlignment") + .that(firstCue.textAlignment) + .isEqualTo(Layout.Alignment.ALIGN_CENTER); + assertWithMessage("Cue.positionAnchor") + .that(firstCue.positionAnchor) + .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); + assertThat(firstCue.position).isEqualTo(0.5f); + assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); + assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); + assertThat(firstCue.line).isEqualTo(0.95f); + + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + } + + @Test + public void decodeTypicalUtf16be() throws IOException { + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(6); + // Check position, line, anchors & alignment are set from Alignment Style (2 - bottom-center). + Cue firstCue = subtitle.getCues(subtitle.getEventTime(0)).get(0); + assertWithMessage("Cue.textAlignment") + .that(firstCue.textAlignment) + .isEqualTo(Layout.Alignment.ALIGN_CENTER); + assertWithMessage("Cue.positionAnchor") + .that(firstCue.positionAnchor) + .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); + assertThat(firstCue.position).isEqualTo(0.5f); + assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); + assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); + assertThat(firstCue.line).isEqualTo(0.95f); + + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + } + @Test public void decodeOverlappingTimecodes() throws IOException { SsaDecoder decoder = new SsaDecoder(); @@ -438,6 +493,10 @@ private static void assertTypicalCue1(Subtitle subtitle, int eventIndex) { assertThat(subtitle.getEventTime(eventIndex)).isEqualTo(0); assertThat(subtitle.getCues(subtitle.getEventTime(eventIndex)).get(0).text.toString()) .isEqualTo("This is the first subtitle."); + assertThat( + Objects.requireNonNull( + subtitle.getCues(subtitle.getEventTime(eventIndex)).get(0).textAlignment)) + .isEqualTo(Layout.Alignment.ALIGN_CENTER); assertThat(subtitle.getEventTime(eventIndex + 1)).isEqualTo(1230000); } diff --git a/testdata/src/test/assets/media/ssa/typical_utf16be b/testdata/src/test/assets/media/ssa/typical_utf16be new file mode 100644 index 0000000000000000000000000000000000000000..6b11ad0ed5760b3219046e3154f66f28f5fb1633 GIT binary patch literal 1460 zcmbW1TW`}q5QXPCzame(5USQmq2!H1D=HO=NT@)-Luxx0H3^lS5(4U9FMMa##x4$0 zL6Pmwz9UJ*wV)K#XeihhM+5(TWWoFEqu?dXAkijfN$KA zyXP?xnJsJt-pBr$)q=PVYuT3I%Pm+=h3|#m8)u;$8M&VmHRrizrz``!V$YQP#p4WL zv7J#VZNwiE72D+&Ty+w!aYFpaGsM1sN2Y{a+3&1@$;z2tVhJ?^8hdPN?+d=={zI|9 zat2~Iapak=)Lq3NqAe?`Ro#{4uC7pPO0I;6b&*M`C*)Jl)oXdZ!Y2b&#nT4zwt<{G za)h_~oOs-XQ?J{HjcV$IMtpN$r7)S<1$jbmyY?902lmo-`5o7O z6)H<6WgMZi;E`d{kL+vF%k2|> zirNhx<#cLtvZC|qVVJ0@cRG>!tr!yf4gVG$oe`^9pU_p^wRWMU?<&H3r%<$uUE1`P zlF0kgJe4^aH|QDKl>9oC@LsV`*^Sv};JWiUbuUc2Ss*?mpQT}9Hr;5QQEUxByiC;$Ke literal 0 HcmV?d00001 diff --git a/testdata/src/test/assets/media/ssa/typical_utf16le b/testdata/src/test/assets/media/ssa/typical_utf16le new file mode 100644 index 0000000000000000000000000000000000000000..da098604d0ebf8d4babfa5ebf7555c44de8a9b3d GIT binary patch literal 1484 zcmbW1-A~g{7{=dg6aNRUym1ofhT`r-Ad;XMH;D$lC~fxN# z8px?52WYF#soM>``xPJccAppnd@rerBAqZnimD#PeCNK);4-#zJca7E?K!$n?2T>l z9M+x+m86u`(FpW~ZCktMq14s4WzUede5aG%ZPxm?!X?|pU(kzcc9HG6rKUYh{DFNd zDth0fACvPMopMSwL3z;u)iO+0RY7&8{&)1T{f@rk?Nc$$-z8mJf~(qrch|EC>t>PJ z7INjNRT)KIpJsO^cwCcL-$wXXcfz{hoN*d(&cSsH%!zx>uKfG2*=epcn;E&>5a(Z+ zM(DP=F;wS@X&ba-JD?9|I)_7f2Q;~E*cX_tocoAeVqV<@EAkI_f;3cnbwzUJ6{?Uw nAg>PD?D#a-^V$V{Nv8jkmpG^gV)b_iG%vSvQU#_wal!dFI}zx_ literal 0 HcmV?d00001