From 1a90c410198132f329e98dbd39137bddb9573412 Mon Sep 17 00:00:00 2001 From: hoyahozz <85336456+hoyahozz@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:31:48 +0900 Subject: [PATCH] Support custom charset detection for SubRip subtitles Standalone SubRip subtitles without a byte order mark are decoded as UTF-8, which corrupts files that use legacy character encodings. Add an injectable CharsetDetector to DefaultSubtitleParserFactory and use it when parsing standalone SubRip files. Keep BOM precedence, UTF-8 fallback, and embedded Matroska/WebM subtitle behavior unchanged. Issue: androidx/media#2247 --- RELEASENOTES.md | 4 + .../extractor/text/CharsetDetector.java | 38 ++++++++ .../text/DefaultSubtitleParserFactory.java | 29 +++++- .../extractor/text/subrip/SubripParser.java | 41 ++++++-- .../DefaultSubtitleParserFactoryTest.java | 67 +++++++++++++ .../text/subrip/SubripParserTest.java | 96 +++++++++++++++++++ 6 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 libraries/extractor/src/main/java/androidx/media3/extractor/text/CharsetDetector.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9474960ee69..d2c2c0ad799 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -57,6 +57,10 @@ * Fix reporting of late video frames with identical release timestamps so that they are reported as dropped instead of skipped. * Text: + * SubRip: Add support for injecting a `CharsetDetector` into + `DefaultSubtitleParserFactory` to detect the character encoding of + standalone SubRip subtitles without a byte order mark + ([#2247](https://github.com/androidx/media/issues/2247)). * Metadata: * Image: * DataSource: diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/CharsetDetector.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/CharsetDetector.java new file mode 100644 index 00000000000..0d6fb82d701 --- /dev/null +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/CharsetDetector.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 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.extractor.text; + +import androidx.annotation.Nullable; +import androidx.media3.common.util.UnstableApi; +import java.nio.charset.Charset; + +/** Detects the character encoding of subtitle byte data. */ +@UnstableApi +public interface CharsetDetector { + + /** + * Detects the character encoding of the requested range of {@code data}. + * + *

The requested range is not guaranteed to contain a complete subtitle file. + * + * @param data The subtitle byte data. + * @param offset The start offset in {@code data}. + * @param length The number of bytes to inspect. + * @return The detected character encoding, or {@code null} if it could not be determined. + */ + @Nullable + Charset detect(byte[] data, int offset, int length); +} diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java index b2413e3be55..9eaa75eedde 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java @@ -46,10 +46,33 @@ *

  • DVB ({@link DvbParser}) *
  • TTML ({@link TtmlParser}) * + * + *

    A {@link CharsetDetector} can be provided to detect the character encoding of standalone + * SubRip subtitles without a byte order mark. */ @UnstableApi public final class DefaultSubtitleParserFactory implements SubtitleParser.Factory { + @Nullable private final CharsetDetector charsetDetector; + + /** Creates an instance that defaults to UTF-8 for SubRip subtitles without a byte order mark. */ + public DefaultSubtitleParserFactory() { + this(/* charsetDetector= */ null); + } + + /** + * Creates an instance that uses {@code charsetDetector} for standalone SubRip subtitles without a + * byte order mark. + * + *

    The detector is not used for SubRip subtitles embedded in a media container because these + * samples may contain only a small part of the subtitle file. + * + * @param charsetDetector The detector to use, or {@code null} to default to UTF-8. + */ + public DefaultSubtitleParserFactory(@Nullable CharsetDetector charsetDetector) { + this.charsetDetector = charsetDetector; + } + @Override public boolean supportsFormat(Format format) { @Nullable String mimeType = format.sampleMimeType; @@ -106,7 +129,7 @@ public SubtitleParser create(Format format) { case MimeTypes.APPLICATION_MP4VTT: return new Mp4WebvttParser(); case MimeTypes.APPLICATION_SUBRIP: - return new SubripParser(); + return new SubripParser(isStandaloneSubrip(format) ? charsetDetector : null); case MimeTypes.APPLICATION_TX3G: return new Tx3gParser(format.initializationData); case MimeTypes.APPLICATION_PGS: @@ -123,4 +146,8 @@ public SubtitleParser create(Format format) { } throw new IllegalArgumentException("Unsupported MIME type: " + mimeType); } + + private static boolean isStandaloneSubrip(Format format) { + return format.containerMimeType == null || MimeTypes.isText(format.containerMimeType); + } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java index 8f607ed6357..16415c73c0f 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java @@ -31,6 +31,7 @@ import androidx.media3.common.util.Log; import androidx.media3.common.util.ParsableByteArray; import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.text.CharsetDetector; import androidx.media3.extractor.text.CuesWithTiming; import androidx.media3.extractor.text.SubtitleParser; import com.google.common.collect.ImmutableList; @@ -82,11 +83,24 @@ public final class SubripParser implements SubtitleParser { private final StringBuilder textBuilder; private final ArrayList tags; private final ParsableByteArray parsableByteArray; + @Nullable private final CharsetDetector charsetDetector; public SubripParser() { + this(/* charsetDetector= */ null); + } + + /** + * Creates an instance that uses {@code charsetDetector} when the input doesn't contain a byte + * order mark. + * + * @param charsetDetector The detector to use, or {@code null} to default to UTF-8 when the input + * doesn't contain a byte order mark. + */ + public SubripParser(@Nullable CharsetDetector charsetDetector) { textBuilder = new StringBuilder(); tags = new ArrayList<>(); parsableByteArray = new ParsableByteArray(); + this.charsetDetector = charsetDetector; } @Override @@ -103,7 +117,7 @@ public void parse( Consumer output) { parsableByteArray.reset(data, /* limit= */ offset + length); parsableByteArray.setPosition(offset); - Charset charset = detectUtfCharset(parsableByteArray); + Charset charset = detectCharset(data, offset, length); @Nullable List cuesWithTimingBeforeRequestedStartTimeUs = @@ -188,12 +202,27 @@ public void parse( } /** - * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if - * no BOM is found. + * Returns the charset to use for line parsing. + * + *

    A byte order mark takes precedence. Otherwise, input detected as anything other than UTF-8 + * or US-ASCII is transcoded to UTF-8 in {@link #parsableByteArray} first. */ - private Charset detectUtfCharset(ParsableByteArray data) { - @Nullable Charset charset = data.readUtfCharsetFromBom(); - return charset != null ? charset : StandardCharsets.UTF_8; + private Charset detectCharset(byte[] data, int offset, int length) { + @Nullable Charset utfCharset = parsableByteArray.readUtfCharsetFromBom(); + if (utfCharset != null) { + return utfCharset; + } + @Nullable + Charset detectedCharset = + charsetDetector != null ? charsetDetector.detect(data, offset, length) : null; + Charset charset = detectedCharset != null ? detectedCharset : StandardCharsets.UTF_8; + if (charset.equals(StandardCharsets.UTF_8) || charset.equals(StandardCharsets.US_ASCII)) { + return charset; + } + // Normalize detected input to UTF-8 so line parsing uses a single code path. + parsableByteArray.reset( + new String(data, offset, length, charset).getBytes(StandardCharsets.UTF_8)); + return StandardCharsets.UTF_8; } /** diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/DefaultSubtitleParserFactoryTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/DefaultSubtitleParserFactoryTest.java index b8bef9fc7f3..3176deac156 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/DefaultSubtitleParserFactoryTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/DefaultSubtitleParserFactoryTest.java @@ -20,11 +20,16 @@ import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; +import androidx.media3.extractor.text.SubtitleParser.OutputOptions; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.base.CharMatcher; import com.google.common.collect.ImmutableList; import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -32,6 +37,64 @@ @RunWith(AndroidJUnit4.class) public class DefaultSubtitleParserFactoryTest { + @Test + public void createStandaloneSubripParser_usesCharsetDetector() { + Charset charset = Charset.forName("GB18030"); + DefaultSubtitleParserFactory factory = + new DefaultSubtitleParserFactory((data, offset, length) -> charset); + Format format = new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build(); + String expectedText = "起来 快起来"; + byte[] bytes = createSubripBytes(expectedText, charset); + + List cues = new ArrayList<>(); + factory.create(format).parse(bytes, OutputOptions.allCues(), cues::add); + + assertThat(cues).hasSize(1); + assertThat(cues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText); + } + + @Test + public void createStandaloneSubripParserWithTextContainerMimeType_usesCharsetDetector() { + Charset charset = Charset.forName("GB18030"); + DefaultSubtitleParserFactory factory = + new DefaultSubtitleParserFactory((data, offset, length) -> charset); + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.APPLICATION_SUBRIP) + .setContainerMimeType(MimeTypes.APPLICATION_SUBRIP) + .build(); + String expectedText = "起来 快起来"; + byte[] bytes = createSubripBytes(expectedText, charset); + + List cues = new ArrayList<>(); + factory.create(format).parse(bytes, OutputOptions.allCues(), cues::add); + + assertThat(cues).hasSize(1); + assertThat(cues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText); + } + + @Test + public void createEmbeddedSubripParser_doesNotUseCharsetDetector() { + DefaultSubtitleParserFactory factory = + new DefaultSubtitleParserFactory( + (data, offset, length) -> { + throw new AssertionError("Charset detector should not be called"); + }); + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.APPLICATION_SUBRIP) + .setContainerMimeType(MimeTypes.VIDEO_MATROSKA) + .build(); + String expectedText = "This is an embedded subtitle."; + byte[] bytes = createSubripBytes(expectedText, StandardCharsets.UTF_8); + + List cues = new ArrayList<>(); + factory.create(format).parse(bytes, OutputOptions.allCues(), cues::add); + + assertThat(cues).hasSize(1); + assertThat(cues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText); + } + /** * This test loops through all the public fields of {@link MimeTypes} and assumes all the static, * string fields with a single "/" in them are MIME types - then it uses these to 'fuzz' the @@ -74,4 +137,8 @@ public void formatSupportIsConsistent() throws Exception { } } } + + private static byte[] createSubripBytes(String text, Charset charset) { + return ("1\r\n" + "00:00:00,000 --> 00:00:05,000\r\n" + text + "\r\n").getBytes(charset); + } } diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java index b8cab4f943c..876ba224748 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java @@ -28,6 +28,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import org.junit.Test; @@ -83,6 +85,89 @@ public void parseTypical() throws IOException { assertTypicalCue3(allCues.get(2)); } + @Test + public void parseGb18030WithCharsetDetector_outputsDecodedText() { + assertTextParsedWithCharsetDetector("起来 快起来", Charset.forName("GB18030")); + } + + @Test + public void parseEucKrWithCharsetDetector_outputsDecodedText() { + assertTextParsedWithCharsetDetector("이것은 한국어 자막 테스트입니다.", Charset.forName("EUC-KR")); + } + + @Test + public void parseShiftJisWithCharsetDetector_outputsDecodedText() { + assertTextParsedWithCharsetDetector("これは日本語の字幕テストです。", Charset.forName("Shift_JIS")); + } + + @Test + public void parseUtf8WithCharsetDetector_outputsDecodedText() { + assertTextParsedWithCharsetDetector("This is a UTF-8 subtitle.", StandardCharsets.UTF_8); + } + + @Test + public void parseUsAsciiWithCharsetDetector_outputsDecodedText() { + assertTextParsedWithCharsetDetector("This is an ASCII subtitle.", StandardCharsets.US_ASCII); + } + + @Test + public void parseWithByteOrderMark_doesNotCallCharsetDetector() throws IOException { + SubripParser parser = + new SubripParser( + (data, offset, length) -> { + throw new AssertionError("Charset detector should not be called"); + }); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK); + + ImmutableList allCues = parseAllCues(parser, bytes); + + assertThat(allCues).hasSize(3); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(1)); + assertTypicalCue3(allCues.get(2)); + } + + @Test + public void parseWithCharsetDetectorReturningNull_defaultsToUtf8() throws IOException { + SubripParser parser = new SubripParser((data, offset, length) -> null); + byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE); + + ImmutableList allCues = parseAllCues(parser, bytes); + + assertThat(allCues).hasSize(3); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(1)); + assertTypicalCue3(allCues.get(2)); + } + + @Test + public void parseAtOffsetWithCharsetDetector_passesRequestedRangeToDetector() { + Charset charset = Charset.forName("GB18030"); + String expectedText = "这是一个字幕测试。"; + byte[] subtitleBytes = + ("1\r\n" + "00:00:00,000 --> 00:00:05,000\r\n" + expectedText + "\r\n").getBytes(charset); + int offset = 5; + byte[] bytes = new byte[offset + subtitleBytes.length + 7]; + System.arraycopy(subtitleBytes, 0, bytes, offset, subtitleBytes.length); + SubripParser parser = + new SubripParser( + (data, detectorOffset, detectorLength) -> { + assertThat(data).isSameInstanceAs(bytes); + assertThat(detectorOffset).isEqualTo(offset); + assertThat(detectorLength).isEqualTo(subtitleBytes.length); + return charset; + }); + ImmutableList.Builder cues = ImmutableList.builder(); + + parser.parse(bytes, offset, subtitleBytes.length, OutputOptions.allCues(), cues::add); + + ImmutableList allCues = cues.build(); + assertThat(allCues).hasSize(1); + assertThat(allCues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText); + } + @Test public void parseTypicalAtOffsetAndRestrictedLength() throws IOException { SubripParser parser = new SubripParser(); @@ -309,6 +394,17 @@ private static ImmutableList parseAllCues(SubtitleParser parser, return cues.build(); } + private static void assertTextParsedWithCharsetDetector(String expectedText, Charset charset) { + byte[] bytes = + ("1\r\n" + "00:00:00,000 --> 00:00:05,000\r\n" + expectedText + "\r\n").getBytes(charset); + SubripParser parser = new SubripParser((data, offset, length) -> charset); + + ImmutableList allCues = parseAllCues(parser, bytes); + + assertThat(allCues).hasSize(1); + assertThat(allCues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText); + } + private static void assertTypicalCue1(CuesWithTiming cuesWithTiming) { assertThat(cuesWithTiming.startTimeUs).isEqualTo(0); assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the first subtitle.");