From 4347bf04161b4a063de5ca15144778e5f7e543bb Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 3 Oct 2018 22:19:13 +0100 Subject: [PATCH] Subrip cleanup --- RELEASENOTES.md | 2 + .../exoplayer2/text/subrip/SubripDecoder.java | 171 ++++++++---------- .../src/test/assets/subrip/typical_with_tags | 4 +- .../text/subrip/SubripDecoderTest.java | 105 +++-------- 4 files changed, 111 insertions(+), 171 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index eb405260df..e5a04470a2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -8,6 +8,8 @@ * Fix issue where subtitles have a wrong position if SubtitleView has a non-zero offset to its parent ([#4788](https://github.com/google/ExoPlayer/issues/4788)). +* SubRip: Add support for alignment tags, and remove tags from the displayed + captions ([#4306](https://github.com/google/ExoPlayer/issues/4306)). ### 2.9.0 ### diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java index 182f1cf4b0..3b039061b0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java @@ -15,9 +15,8 @@ */ package com.google.android.exoplayer2.text.subrip; -import android.support.annotation.StringDef; +import android.support.annotation.Nullable; import android.text.Html; -import android.text.Layout; import android.text.Spanned; import android.text.TextUtils; import com.google.android.exoplayer2.text.Cue; @@ -25,9 +24,6 @@ import com.google.android.exoplayer2.text.SimpleSubtitleDecoder; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.LongArray; import com.google.android.exoplayer2.util.ParsableByteArray; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -37,6 +33,11 @@ import java.util.regex.Pattern; */ public final class SubripDecoder extends SimpleSubtitleDecoder { + // Fractional positions for use when alignment tags are present. + /* package */ static final float START_FRACTION = 0.08f; + /* package */ static final float END_FRACTION = 1 - START_FRACTION; + /* package */ static final float MID_FRACTION = 0.5f; + private static final String TAG = "SubripDecoder"; private static final String SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+),(\\d+)"; @@ -46,35 +47,24 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { private static final Pattern SUBRIP_TAG_PATTERN = Pattern.compile("\\{\\\\.*?\\}"); private static final String SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}"; - private static final float DEFAULT_START_FRACTION = 0.08f; - private static final float DEFAULT_END_FRACTION = 1 - DEFAULT_START_FRACTION; - private static final float DEFAULT_MID_FRACTION = 0.5f; - - @Retention(RetentionPolicy.SOURCE) - @StringDef({ - ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT, - ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT, - ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT - }) - - private @interface SubRipTag {} - - // Possible valid alignment tags based on SSA v4+ specs - private static final String ALIGN_BOTTOM_LEFT = "{\\an1}"; - private static final String ALIGN_BOTTOM_MID = "{\\an2}"; + // Alignment tags for SSA V4+. + private static final String ALIGN_BOTTOM_LEFT = "{\\an1}"; + private static final String ALIGN_BOTTOM_MID = "{\\an2}"; private static final String ALIGN_BOTTOM_RIGHT = "{\\an3}"; - private static final String ALIGN_MID_LEFT = "{\\an4}"; - private static final String ALIGN_MID_MID = "{\\an5}"; - private static final String ALIGN_MID_RIGHT = "{\\an6}"; - private static final String ALIGN_TOP_LEFT = "{\\an7}"; - private static final String ALIGN_TOP_MID = "{\\an8}"; - private static final String ALIGN_TOP_RIGHT = "{\\an9}"; + private static final String ALIGN_MID_LEFT = "{\\an4}"; + private static final String ALIGN_MID_MID = "{\\an5}"; + private static final String ALIGN_MID_RIGHT = "{\\an6}"; + private static final String ALIGN_TOP_LEFT = "{\\an7}"; + private static final String ALIGN_TOP_MID = "{\\an8}"; + private static final String ALIGN_TOP_RIGHT = "{\\an9}"; private final StringBuilder textBuilder; + private final ArrayList tags; public SubripDecoder() { super("SubripDecoder"); textBuilder = new StringBuilder(); + tags = new ArrayList<>(); } @Override @@ -118,9 +108,9 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { continue; } - // Read and parse the text. - ArrayList tags = new ArrayList<>(); + // Read and parse the text and tags. textBuilder.setLength(0); + tags.clear(); while (!TextUtils.isEmpty(currentLine = subripData.readLine())) { if (textBuilder.length() > 0) { textBuilder.append("
"); @@ -129,21 +119,17 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { } Spanned text = Html.fromHtml(textBuilder.toString()); - Cue cue = null; - // At end of this loop the clue must be created with the applied tags - for (String tag : tags) { - - // Check if the tag is an alignment tag + String alignmentTag = null; + for (int i = 0; i < tags.size(); i++) { + String tag = tags.get(i); if (tag.matches(SUBRIP_ALIGNMENT_TAG)) { - cue = buildCue(text, tag); - - // Based on the specs, in case of alignment tags only the first appearance counts, so break + alignmentTag = tag; + // Subsequent alignment tags should be ignored. break; } } - - cues.add(cue == null ? new Cue(text) : cue); + cues.add(buildCue(text, alignmentTag)); if (haveEndTimecode) { cues.add(null); @@ -157,108 +143,93 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { } /** - * Process the given line by first trimming it then extracting the tags from it - *

- * The pattern that is used to extract the tags is specified in SSA v4+ specs and - * has the following form: "{\...}". - *

- * "All override codes appear within braces {}" - * "All override codes are always preceded by a backslash \" + * Trims and removes tags from the given line. The removed tags are added to {@code tags}. * - * @param currentLine Current line - * @param tags Extracted tags will be stored in this array list - * @return Processed line + * @param line The line to process. + * @param tags A list to which removed tags will be added. + * @return The processed line. */ - private String processLine(String currentLine, ArrayList tags) { - // Trim line - String trimmedLine = currentLine.trim(); - - // Extract tags - int replacedCharacters = 0; - StringBuilder processedLine = new StringBuilder(trimmedLine); - Matcher matcher = SUBRIP_TAG_PATTERN.matcher(trimmedLine); + private String processLine(String line, ArrayList tags) { + line = line.trim(); + int removedCharacterCount = 0; + StringBuilder processedLine = new StringBuilder(line); + Matcher matcher = SUBRIP_TAG_PATTERN.matcher(line); while (matcher.find()) { String tag = matcher.group(); tags.add(tag); - processedLine.replace(matcher.start() - replacedCharacters, matcher.end() - replacedCharacters, ""); - replacedCharacters += tag.length(); + int start = matcher.start() - removedCharacterCount; + int tagLength = tag.length(); + processedLine.replace(start, /* end= */ start + tagLength, /* str= */ ""); + removedCharacterCount += tagLength; } return processedLine.toString(); } /** - * Build a {@link Cue} based on the given text and tag - *

- * Match the alignment tag and calculate the line, position, position anchor accordingly - *

- * Based on SSA v4+ specs the alignment tag can have the following form: {\an[1-9}, - * where the number specifies the direction (based on the numpad layout). - * Note. older SSA scripts may contain tags like {\a1[1-9]} but these are based on - * other direction rules, but multiple sources says that these are deprecated, so no support here either + * Build a {@link Cue} based on the given text and alignment tag. * - * @param alignmentTag Alignment tag + * @param text The text. + * @param alignmentTag The alignment tag, or {@code null} if no alignment tag is available. * @return Built cue */ - private Cue buildCue(Spanned text, String alignmentTag) { - float line, position; - @Cue.AnchorType int positionAnchor; - @Cue.AnchorType int lineAnchor; + private Cue buildCue(Spanned text, @Nullable String alignmentTag) { + if (alignmentTag == null) { + return new Cue(text); + } - // Set position and position anchor (horizontal alignment) + // Horizontal alignment. + @Cue.AnchorType int positionAnchor; switch (alignmentTag) { case ALIGN_BOTTOM_LEFT: case ALIGN_MID_LEFT: case ALIGN_TOP_LEFT: - position = DEFAULT_START_FRACTION; positionAnchor = Cue.ANCHOR_TYPE_START; break; - case ALIGN_BOTTOM_MID: - case ALIGN_MID_MID: - case ALIGN_TOP_MID: - position = DEFAULT_MID_FRACTION; - positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; - break; case ALIGN_BOTTOM_RIGHT: case ALIGN_MID_RIGHT: case ALIGN_TOP_RIGHT: - position = DEFAULT_END_FRACTION; positionAnchor = Cue.ANCHOR_TYPE_END; break; + case ALIGN_BOTTOM_MID: + case ALIGN_MID_MID: + case ALIGN_TOP_MID: default: - position = DEFAULT_MID_FRACTION; positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; break; } - // Set line and line anchor (vertical alignment) + // Vertical alignment. + @Cue.AnchorType int lineAnchor; switch (alignmentTag) { case ALIGN_BOTTOM_LEFT: case ALIGN_BOTTOM_MID: case ALIGN_BOTTOM_RIGHT: - line = DEFAULT_END_FRACTION; lineAnchor = Cue.ANCHOR_TYPE_END; break; - case ALIGN_MID_LEFT: - case ALIGN_MID_MID: - case ALIGN_MID_RIGHT: - line = DEFAULT_MID_FRACTION; - lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; - break; case ALIGN_TOP_LEFT: case ALIGN_TOP_MID: case ALIGN_TOP_RIGHT: - line = DEFAULT_START_FRACTION; lineAnchor = Cue.ANCHOR_TYPE_START; break; + case ALIGN_MID_LEFT: + case ALIGN_MID_MID: + case ALIGN_MID_RIGHT: default: - line = DEFAULT_END_FRACTION; - lineAnchor = Cue.ANCHOR_TYPE_END; + lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; break; } - return new Cue(text, null, line, Cue.LINE_TYPE_FRACTION, lineAnchor, position, positionAnchor, Cue.DIMEN_UNSET); + return new Cue( + text, + /* textAlignment= */ null, + getFractionalPositionForAnchorType(lineAnchor), + Cue.LINE_TYPE_FRACTION, + lineAnchor, + getFractionalPositionForAnchorType(positionAnchor), + positionAnchor, + Cue.DIMEN_UNSET); } private static long parseTimecode(Matcher matcher, int groupOffset) { @@ -268,4 +239,16 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { timestampMs += Long.parseLong(matcher.group(groupOffset + 4)); return timestampMs * 1000; } + + /* package */ static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) { + switch (anchorType) { + case Cue.ANCHOR_TYPE_START: + return SubripDecoder.START_FRACTION; + case Cue.ANCHOR_TYPE_MIDDLE: + return SubripDecoder.MID_FRACTION; + case Cue.ANCHOR_TYPE_END: + default: + return SubripDecoder.END_FRACTION; + } + } } diff --git a/library/core/src/test/assets/subrip/typical_with_tags b/library/core/src/test/assets/subrip/typical_with_tags index af196f8a04..85e304b498 100644 --- a/library/core/src/test/assets/subrip/typical_with_tags +++ b/library/core/src/test/assets/subrip/typical_with_tags @@ -13,7 +13,7 @@ This {\an2} is the third {\ tag} subtitle. 4 00:00:09,567 --> 00:00:12,901 -This { \an2} is the fourth subtitle. +This { \an2} is not a valid tag due to the space after the opening bracket. 5 00:00:013,567 --> 00:00:14,901 @@ -53,4 +53,4 @@ This {\an8} is a line. 14 00:00:024,567 --> 00:00:24,901 -This {\an9} is a line. \ No newline at end of file +This {\an9} is a line. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java index 554184da5d..1430c70e09 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java @@ -19,7 +19,6 @@ import static com.google.common.truth.Truth.assertThat; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.text.Cue; - import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; @@ -158,89 +157,30 @@ public final class SubripDecoderTest { } @Test - public void testDecodeCueWithTag() throws IOException{ + public void testDecodeCueWithTag() throws IOException { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(RuntimeEnvironment.application, TYPICAL_WITH_TAGS); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); - assertThat(subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()) - .isEqualTo("This is the first subtitle."); - assertThat(subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()) - .isEqualTo("This is the second subtitle.\nSecond subtitle with second line."); - assertThat(subtitle.getCues(subtitle.getEventTime(4)).get(0).text.toString()) - .isEqualTo("This is the third subtitle."); - // Based on the SSA v4+ specs the curly bracket must be followed by a backslash, so this is - // not a valid tag (won't be parsed / replaced) + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + assertThat(subtitle.getCues(subtitle.getEventTime(6)).get(0).text.toString()) - .isEqualTo("This { \\an2} is the fourth subtitle."); + .isEqualTo("This { \\an2} is not a valid tag due to the space after the opening bracket."); assertThat(subtitle.getCues(subtitle.getEventTime(8)).get(0).text.toString()) .isEqualTo("This is the fifth subtitle with multiple valid tags."); - // Verify positions - - // {/an1} - assertThat(subtitle.getCues(subtitle.getEventTime(10)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_START); - - assertThat(subtitle.getCues(subtitle.getEventTime(10)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_END); - - // {/an2} - assertThat(subtitle.getCues(subtitle.getEventTime(12)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); - - assertThat(subtitle.getCues(subtitle.getEventTime(12)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_END); - - // {/an3} - assertThat(subtitle.getCues(subtitle.getEventTime(14)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_END); - - assertThat(subtitle.getCues(subtitle.getEventTime(14)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_END); - - // {/an4} - assertThat(subtitle.getCues(subtitle.getEventTime(16)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_START); - - assertThat(subtitle.getCues(subtitle.getEventTime(16)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); - - // {/an5} - assertThat(subtitle.getCues(subtitle.getEventTime(18)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); - - assertThat(subtitle.getCues(subtitle.getEventTime(18)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); - - // {/an6} - assertThat(subtitle.getCues(subtitle.getEventTime(20)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_END); - - assertThat(subtitle.getCues(subtitle.getEventTime(20)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); - - // {/an7} - assertThat(subtitle.getCues(subtitle.getEventTime(22)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_START); - - assertThat(subtitle.getCues(subtitle.getEventTime(22)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_START); - - // {/an8} - assertThat(subtitle.getCues(subtitle.getEventTime(24)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); - - assertThat(subtitle.getCues(subtitle.getEventTime(24)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_START); - - // {/an9} - assertThat(subtitle.getCues(subtitle.getEventTime(26)).get(0).positionAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_END); - - assertThat(subtitle.getCues(subtitle.getEventTime(26)).get(0).lineAnchor) - .isEqualTo(Cue.ANCHOR_TYPE_START); + assertAlignmentCue(subtitle, 10, Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_START); // {/an1} + assertAlignmentCue(subtitle, 12, Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_MIDDLE); // {/an2} + assertAlignmentCue(subtitle, 14, Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_END); // {/an3} + assertAlignmentCue(subtitle, 16, Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_START); // {/an4} + assertAlignmentCue(subtitle, 18, Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_MIDDLE); // {/an5} + assertAlignmentCue(subtitle, 20, Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_END); // {/an6} + assertAlignmentCue(subtitle, 22, Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_START); // {/an7} + assertAlignmentCue(subtitle, 24, Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_MIDDLE); // {/an8} + assertAlignmentCue(subtitle, 26, Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_END); // {/an9} } private static void assertTypicalCue1(SubripSubtitle subtitle, int eventIndex) { @@ -263,4 +203,19 @@ public final class SubripDecoderTest { .isEqualTo("This is the third subtitle."); assertThat(subtitle.getEventTime(eventIndex + 1)).isEqualTo(8901000); } + + private static void assertAlignmentCue( + SubripSubtitle subtitle, + int eventIndex, + @Cue.AnchorType int lineAnchor, + @Cue.AnchorType int positionAnchor) { + long eventTimeUs = subtitle.getEventTime(eventIndex); + Cue cue = subtitle.getCues(eventTimeUs).get(0); + assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); + assertThat(cue.lineAnchor).isEqualTo(lineAnchor); + assertThat(cue.line).isEqualTo(SubripDecoder.getFractionalPositionForAnchorType(lineAnchor)); + assertThat(cue.positionAnchor).isEqualTo(positionAnchor); + assertThat(cue.position) + .isEqualTo(SubripDecoder.getFractionalPositionForAnchorType(positionAnchor)); + } }