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 @@ *
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 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