This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 9708ca03e6 TIKA-5028: add SubtitleParser for WebVTT and SubRip; fix 
SubRip and W… (#3523)
9708ca03e6 is described below

commit 9708ca03e6b182196c6521359a864ad693c4cad6
Author: Tim Allison <[email protected]>
AuthorDate: Wed Nov 4 15:52:18 2026 -0500

    TIKA-5028: add SubtitleParser for WebVTT and SubRip; fix SubRip and W… 
(#3523)
---
 CHANGES.txt                                        |   8 +
 docs/modules/ROOT/pages/formats.adoc               |   5 +
 docs/modules/ROOT/partials/supported-formats.adoc  |   3 +
 .../org/apache/tika/mime/tika-mimetypes.xml        |  59 ++-
 .../tika/config/TikaEncodingDetectorTest.java      |   6 +-
 .../tika/parser/subtitle/SubtitleParser.java       | 522 +++++++++++++++++++++
 .../tika/parser/subtitle/SubtitleParserTest.java   | 285 +++++++++++
 7 files changed, 853 insertions(+), 35 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 1294b4607e..e03b03d1e9 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -56,6 +56,14 @@ Release 4.3.0 - ???
 
   EXTRACTION AND FORMATS
 
+   * New SubtitleParser for WebVTT (.vtt) and SubRip (.srt). Each cue becomes
+     <div class="cue"> with data-start/data-end (HH:MM:SS.mmm) and data-id, 
its text
+     cleaned of markup; WebVTT NOTE blocks become <div class="note"> and a 
Language
+     header sets dc:language. Both were parsed as plain text before, timings 
and
+     markup included. Magic fixes: SubRip with CRLF line endings (typo in the 
rule);
+     WebVTT with a title or tab after "WEBVTT", with a UTF-8 BOM and a title, 
or in
+     UTF-16 (TIKA-5028).
+
    * Detect Python scripts with /usr/bin/env python shebangs as text/x-python
      instead of application/x-sh (TIKA-4408).
 
diff --git a/docs/modules/ROOT/pages/formats.adoc 
b/docs/modules/ROOT/pages/formats.adoc
index 7dc14bc98e..2a6c0d6fe7 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -161,6 +161,11 @@ CSV and TSV, emitting tables for those.
 link:{api}/org/apache/tika/parser/markdown/MarkdownParser.html[MarkdownParser]
 parses Markdown into structured XHTML rather than treating it as plain text.
 
+link:{api}/org/apache/tika/parser/subtitle/SubtitleParser.html[SubtitleParser]
+extracts WebVTT and SubRip subtitles: one `<div class="cue">` per cue, carrying
+its start and end time (`data-start`, `data-end`, as `HH:MM:SS.mmm`) and
+identifier (`data-id`), with styling markup removed from the text.
+
 == Feed and syndication formats
 
 link:{api}/org/apache/tika/parser/feed/FeedParser.html[FeedParser] supports the
diff --git a/docs/modules/ROOT/partials/supported-formats.adoc 
b/docs/modules/ROOT/partials/supported-formats.adoc
index cb64d42658..aa7b0219d6 100644
--- a/docs/modules/ROOT/partials/supported-formats.adoc
+++ b/docs/modules/ROOT/partials/supported-formats.adoc
@@ -368,6 +368,9 @@
 ** `application/x-prt`
 * `org.apache.tika.parser.sas.` 
link:{tika-javadoc-url}/org/apache/tika/parser/sas/SAS7BDATParser.html[SAS7BDATParser]
 ** `application/x-sas-data`
+* `org.apache.tika.parser.subtitle.` 
link:{tika-javadoc-url}/org/apache/tika/parser/subtitle/SubtitleParser.html[SubtitleParser]
+** `application/x-subrip`
+** `text/vtt`
 * `org.apache.tika.parser.tmx.` 
link:{tika-javadoc-url}/org/apache/tika/parser/tmx/TMXParser.html[TMXParser]
 ** `application/x-tmx`
 * `org.apache.tika.parser.video.` 
link:{tika-javadoc-url}/org/apache/tika/parser/video/FLVParser.html[FLVParser]
diff --git 
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml 
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index bcf2815e4d..36fbecbea0 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -2983,13 +2983,15 @@
   <mime-type type="application/x-subrip">
     <_comment>SubRip (srt) subtitles</_comment>
     <magic priority="50">
-      <!-- 1[CR|LF|CRLF]00 -->
+      <!-- 1[CR|LF|CRLF|CRCRLF]00 -->
       <match value="0x310A3030" type="string" offset="0"/>
       <match value="0x310D3030" type="string" offset="0"/>
-      <match value="0x310D0A13030" type="string" offset="0"/>
+      <match value="0x310D0A3030" type="string" offset="0"/>
+      <match value="0x310D0D0A3030" type="string" offset="0"/>
       <match value="0xEFBBBF310A3030" type="string" offset="0"/>
       <match value="0xEFBBBF310D3030" type="string" offset="0"/>
-      <match value="0xEFBBBF310D0A13030" type="string" offset="0"/>
+      <match value="0xEFBBBF310D0A3030" type="string" offset="0"/>
+      <match value="0xEFBBBF310D0D0A3030" type="string" offset="0"/>
     </magic>
     <glob pattern="*.srt"/>
     <sub-class-of type="text/plain"/>
@@ -8177,37 +8179,30 @@
   <mime-type type="text/vtt">
     <_comment>Web Video Text Tracks Format</_comment>
     <acronym>WebVTT</acronym>
+    <!-- "WEBVTT" then a space, tab or line terminator; UTF-8 BOM optional -->
     <magic priority="40">
-      <!-- Simple Form -->
-      <match value="WEBVTT\r" type="string" offset="0"/>
       <match value="WEBVTT\n" type="string" offset="0"/>
-      <!-- With Byte Order Mark -->
-      <match value="0xfeff" offset="0">
-         <match value="WEBVTT\r" type="string" offset="2"/>
-         <match value="WEBVTT\n" type="string" offset="2"/>
-      </match>
-      <match value="0xfeff" offset="0">
-         <match value="WEBVTT\r" type="string" offset="2"/>
-         <match value="WEBVTT\n" type="string" offset="2"/>
-      </match>
-      <match value="0xefbbbf" offset="0">
-         <match value="WEBVTT\r" type="string" offset="3"/>
-         <match value="WEBVTT\n" type="string" offset="3"/>
-      </match>
-      <!-- Common Header -->
-      <match value="WEBVTT FILE\r" type="string" offset="0"/>
-      <match value="WEBVTT FILE\n" type="string" offset="0"/>
-    </magic>
-    <magic priority="30">
-      <!-- With a custom header - needs a lower priority -->
-      <match value="WEBVTT " type="string" offset="0">
-        <match value="\n\n" type="string" offset="10:50" />
-      </match>
-      <match value="WEBVTT " type="string" offset="0">
-        <match value="\r\r" type="string" offset="10:50" />
-      </match>
-      <match value="WEBVTT " type="string" offset="0">
-        <match value="\r\n\r\n" type="string" offset="10:50" />
+      <match value="WEBVTT\r" type="string" offset="0"/>
+      <match value="WEBVTT " type="string" offset="0"/>
+      <match value="WEBVTT\t" type="string" offset="0"/>
+      <match value="0xefbbbf" type="string" offset="0">
+        <match value="WEBVTT\n" type="string" offset="3"/>
+        <match value="WEBVTT\r" type="string" offset="3"/>
+        <match value="WEBVTT " type="string" offset="3"/>
+        <match value="WEBVTT\t" type="string" offset="3"/>
+      </match>
+      <!-- UTF-16 is outside the spec, but some editors write it -->
+      <match value="0xfeff005700450042005600540054" type="string" offset="0">
+        <match value="0x000a" type="string" offset="14"/>
+        <match value="0x000d" type="string" offset="14"/>
+        <match value="0x0020" type="string" offset="14"/>
+        <match value="0x0009" type="string" offset="14"/>
+      </match>
+      <match value="0xfffe570045004200560054005400" type="string" offset="0">
+        <match value="0x0a00" type="string" offset="14"/>
+        <match value="0x0d00" type="string" offset="14"/>
+        <match value="0x2000" type="string" offset="14"/>
+        <match value="0x0900" type="string" offset="14"/>
       </match>
     </magic>
     <glob pattern="*.vtt"/>
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/config/TikaEncodingDetectorTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/config/TikaEncodingDetectorTest.java
index 84828bb3d6..2f7160d63e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/config/TikaEncodingDetectorTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/config/TikaEncodingDetectorTest.java
@@ -147,7 +147,7 @@ public class TikaEncodingDetectorTest extends TikaTest {
         List<Parser> parsers = new ArrayList<>();
         findEncodingDetectionParsers(p, parsers);
 
-        assertEquals(9, parsers.size());
+        assertEquals(10, parsers.size());
         EncodingDetector encodingDetector =
                 ((AbstractEncodingDetectorParser) 
parsers.get(0)).getEncodingDetector();
         assertTrue(encodingDetector instanceof CompositeEncodingDetector);
@@ -177,7 +177,7 @@ public class TikaEncodingDetectorTest extends TikaTest {
         List<Parser> parsers = new ArrayList<>();
         findEncodingDetectionParsers(p, parsers);
 
-        assertEquals(10, parsers.size());
+        assertEquals(11, parsers.size());
 
         for (Parser encodingDetectingParser : parsers) {
             EncodingDetector encodingDetector =
@@ -203,7 +203,7 @@ public class TikaEncodingDetectorTest extends TikaTest {
         List<Parser> parsers = new ArrayList<>();
         findEncodingDetectionParsers(p, parsers);
 
-        assertEquals(9, parsers.size());
+        assertEquals(10, parsers.size());
         for (Parser childParser : parsers) {
             EncodingDetector encodingDetector =
                     ((AbstractEncodingDetectorParser) 
childParser).getEncodingDetector();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/subtitle/SubtitleParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/subtitle/SubtitleParser.java
new file mode 100644
index 0000000000..622d495fbb
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/subtitle/SubtitleParser.java
@@ -0,0 +1,522 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.tika.parser.subtitle;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.AttributesImpl;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.detect.AutoDetectReader;
+import org.apache.tika.detect.EncodingDetector;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AbstractEncodingDetectorParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Parser for WebVTT ({@code text/vtt}) and SubRip ({@code 
application/x-subrip}) subtitles.
+ * Each cue becomes {@code <div class="cue" data-start data-end data-id>} with 
its text in
+ * {@code <p>}; times are normalized to {@code HH:MM:SS.mmm}. WebVTT {@code 
NOTE} blocks become
+ * {@code <div class="note">}, a header {@code Language:} sets {@code 
dc:language}; styling
+ * markup, cue settings and {@code STYLE}/{@code REGION} blocks are dropped. 
Lenient about the
+ * common SubRip defects: missing blank lines between cues and blank lines 
inside a cue.
+ */
+@TikaComponent
+public class SubtitleParser extends AbstractEncodingDetectorParser {
+
+    private static final long serialVersionUID = 4127915370255834561L;
+
+    static final MediaType VTT = MediaType.text("vtt");
+    static final MediaType SUBRIP = MediaType.application("x-subrip");
+
+    private static final Set<MediaType> SUPPORTED_TYPES = Set.of(VTT, SUBRIP);
+
+    // longer lines are split, not buffered: a hostile file may be one endless 
line
+    static final int MAX_LINE_CHARS = 64 * 1024;
+
+    // [hh:]mm:ss[.,]mmm; lenient on digit counts and separator, as real SRT is
+    private static final String TIME = 
"((?:\\d{1,9}:)?\\d{1,2}:\\d{1,2}(?:[.,:]\\d{1,3})?)";
+    private static final Pattern TIMING =
+            Pattern.compile("^\\s*" + TIME + "\\s*-->(?:\\s*" + TIME + ")?");
+    private static final Pattern LANGUAGE_HEADER =
+            Pattern.compile("Language\\s*:\\s*(\\S.{0,63})", 
Pattern.CASE_INSENSITIVE);
+
+    // WebVTT escapes '<', so every '<' opens a tag (class, voice, lang, ruby, 
timestamp)
+    private static final Pattern VTT_TAG = Pattern.compile("<[^>]*>?");
+    // SubRip has no escaping: strip the tags players honor and any closed tag 
pair, keep a
+    // literal "a < b"
+    private static final Pattern SRT_TAG =
+            Pattern.compile("</?(?:i|b|u|s|font)(?:\\s[^<>]*)?>", 
Pattern.CASE_INSENSITIVE);
+    private static final Pattern ANY_TAG = 
Pattern.compile("<(/?)([A-Za-z][\\w-]*)(\\s[^<>]*)?>");
+    // SSA override blocks some SubRip writers carry over, e.g. {\an8}
+    private static final Pattern SSA_OVERRIDE = 
Pattern.compile("\\{\\\\[^{}]*}");
+    private static final Pattern VTT_ENTITY =
+            
Pattern.compile("&(amp|lt|gt|nbsp|lrm|rlm|#\\d{1,7}|#[xX][0-9a-fA-F]{1,6});");
+
+    public SubtitleParser() {
+    }
+
+    public SubtitleParser(EncodingDetector encodingDetector) {
+        super(encodingDetector);
+    }
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) throws IOException, SAXException, 
TikaException {
+        MediaType declared = 
MediaType.parse(metadata.get(HttpHeaders.CONTENT_TYPE));
+        MediaType type = declared != null && 
VTT.equals(declared.getBaseType()) ? VTT : SUBRIP;
+        tis.setCloseShield();
+        try (AutoDetectReader reader = new AutoDetectReader(tis, metadata,
+                getEncodingDetector(context))) {
+            Charset charset = reader.getCharset();
+            metadata.set(HttpHeaders.CONTENT_TYPE, new MediaType(type, 
charset).toString());
+            metadata.set(HttpHeaders.CONTENT_ENCODING, charset.name());
+
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
+            xhtml.startDocument();
+            try {
+                new CueWriter(xhtml, metadata, type == VTT).write(new 
LineReader(reader));
+            } finally {
+                xhtml.endDocument();
+            }
+        } finally {
+            tis.removeCloseShield();
+        }
+    }
+
+    /** {@code HH:MM:SS.mmm} for a time {@link #TIMING} matched, or null if 
out of range. */
+    static String normalizeTime(String raw) {
+        // TIMING guarantees 2-4 digit runs separated by ':', '.' or ','
+        int[] starts = new int[4];
+        int[] ends = new int[4];
+        int n = 0;
+        boolean fraction = false;
+        for (int i = 0; i <= raw.length(); i++) {
+            if (i == raw.length() || !isDigit(raw.charAt(i))) {
+                ends[n++] = i;
+                if (i < raw.length()) {
+                    fraction |= raw.charAt(i) != ':';
+                    starts[n] = i + 1;
+                }
+            }
+        }
+        fraction |= n == 4;
+        int whole = fraction ? n - 1 : n;
+        long h = whole == 3 ? Long.parseLong(raw, starts[0], ends[0], 10) : 0;
+        int m = Integer.parseInt(raw, starts[whole - 2], ends[whole - 2], 10);
+        int s = Integer.parseInt(raw, starts[whole - 1], ends[whole - 1], 10);
+        if (m > 59 || s > 59) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder(16);
+        pad2(sb, h).append(':');
+        pad2(sb, m).append(':');
+        pad2(sb, s).append('.');
+        if (fraction) {
+            sb.append(raw, starts[n - 1], ends[n - 1]);
+            for (int i = ends[n - 1] - starts[n - 1]; i < 3; i++) {
+                sb.append('0');
+            }
+        } else {
+            sb.append("000");
+        }
+        return sb.toString();
+    }
+
+    private static boolean isDigit(char c) {
+        return c >= '0' && c <= '9';
+    }
+
+    private static StringBuilder pad2(StringBuilder sb, long v) {
+        return (v < 10 ? sb.append('0') : sb).append(v);
+    }
+
+    static String clean(String line, boolean vtt) {
+        boolean tag = line.indexOf('<') >= 0;
+        if (vtt) {
+            if (!tag) {
+                return decodeEntities(line);
+            }
+            return decodeEntities(VTT_TAG.matcher(line).replaceAll(""));
+        }
+        if (tag) {
+            line = SRT_TAG.matcher(line).replaceAll("");
+            if (line.indexOf('<') >= 0) {
+                line = unwrapTagPairs(line);
+            }
+        }
+        return line.indexOf('{') < 0 ? line : 
SSA_OVERRIDE.matcher(line).replaceAll("");
+    }
+
+    /** Removes each open tag that a later same-name close tag matches, and 
that close tag. */
+    private static String unwrapTagPairs(String line) {
+        Map<String, ArrayDeque<int[]>> open = new HashMap<>();
+        boolean[] drop = new boolean[line.length()];
+        boolean any = false;
+        Matcher m = ANY_TAG.matcher(line);
+        while (m.find()) {
+            String name = m.group(2).toLowerCase(Locale.ROOT);
+            if (m.group(1).isEmpty()) {
+                open.computeIfAbsent(name, k -> new ArrayDeque<>())
+                        .push(new int[]{m.start(), m.end()});
+                continue;
+            }
+            ArrayDeque<int[]> opens = open.get(name);
+            if ((m.group(3) == null || m.group(3).isBlank()) && opens != null
+                    && !opens.isEmpty()) {
+                int[] o = opens.pop();
+                markRange(drop, o[0], o[1]);
+                markRange(drop, m.start(), m.end());
+                any = true;
+            }
+        }
+        if (!any) {
+            return line;
+        }
+        StringBuilder sb = new StringBuilder(line.length());
+        for (int i = 0; i < line.length(); i++) {
+            if (!drop[i]) {
+                sb.append(line.charAt(i));
+            }
+        }
+        return sb.toString();
+    }
+
+    private static void markRange(boolean[] drop, int start, int end) {
+        for (int i = start; i < end; i++) {
+            drop[i] = true;
+        }
+    }
+
+    private static String decodeEntities(String s) {
+        if (s.indexOf('&') < 0) {
+            return s;
+        }
+        Matcher m = VTT_ENTITY.matcher(s);
+        StringBuilder sb = new StringBuilder(s.length());
+        while (m.find()) {
+            m.appendReplacement(sb, 
Matcher.quoteReplacement(entity(m.group(1), m.group())));
+        }
+        m.appendTail(sb);
+        return sb.toString();
+    }
+
+    private static String entity(String name, String raw) {
+        switch (name) {
+            case "amp":
+                return "&";
+            case "lt":
+                return "<";
+            case "gt":
+                return ">";
+            case "nbsp":
+                return " ";
+            case "lrm":
+                return "‎";
+            case "rlm":
+                return "‏";
+            default:
+                break;
+        }
+        boolean hex = name.length() > 1 && (name.charAt(1) == 'x' || 
name.charAt(1) == 'X');
+        int cp = Integer.parseInt(name.substring(hex ? 2 : 1), hex ? 16 : 10);
+        boolean ok = cp > 0 && Character.isValidCodePoint(cp)
+                && !(cp >= Character.MIN_SURROGATE && cp <= 
Character.MAX_SURROGATE);
+        return ok ? new String(Character.toChars(cp)) : raw;
+    }
+
+    private static final class CueWriter {
+        private enum Block { NONE, HEADER, NOTE, SKIP, CUE }
+
+        private final XHTMLContentHandler xhtml;
+        private final Metadata metadata;
+        private final boolean vtt;
+        private Block block = Block.NONE;
+        private AttributesImpl pendingCue;
+        private String pendingId;
+        private boolean divOpen;
+        private boolean pOpen;
+        // each line is checked as the look-ahead, then again as the current 
line
+        private String timingLine;
+        private Matcher timingMatch;
+
+        CueWriter(XHTMLContentHandler xhtml, Metadata metadata, boolean vtt) {
+            this.xhtml = xhtml;
+            this.metadata = metadata;
+            this.vtt = vtt;
+        }
+
+        void write(LineReader lines) throws IOException, SAXException {
+            boolean blockStart = true;
+            boolean first = true;
+            try {
+                String line = lines.next();
+                while (line != null) {
+                    String next = lines.next();
+                    if (first && line.startsWith("\uFEFF")) {
+                        line = line.substring(1);
+                    }
+                    if (line.isBlank()) {
+                        closeP();
+                        if (block != Block.CUE) {
+                            closeDiv();
+                            block = Block.NONE;
+                        }
+                        blockStart = true;
+                    } else if (blockStart && vtt && startMetaBlock(line, 
first)) {
+                        blockStart = false;
+                    } else {
+                        line(line, next, blockStart);
+                        blockStart = false;
+                    }
+                    first = false;
+                    line = next;
+                }
+            } finally {
+                closeP();
+                closeDiv();
+            }
+        }
+
+        private boolean startMetaBlock(String line, boolean first)
+                throws SAXException {
+            if (first && startsWithKeyword(line, "WEBVTT")) {
+                block = Block.HEADER;
+            } else if (startsWithKeyword(line, "NOTE")) {
+                closeDiv();
+                block = Block.NOTE;
+                text(line.substring(4));
+            } else if (startsWithKeyword(line, "STYLE") || 
startsWithKeyword(line, "REGION")) {
+                closeDiv();
+                block = Block.SKIP;
+            } else {
+                return false;
+            }
+            return true;
+        }
+
+        private void line(String line, String next, boolean blockStart) throws 
SAXException {
+            // a cue right after the header, without the blank line the spec 
requires
+            if (block == Block.HEADER && timing(line) != null) {
+                block = Block.NONE;
+            }
+            switch (block) {
+                case HEADER:
+                    Matcher m = LANGUAGE_HEADER.matcher(line);
+                    if (m.matches()) {
+                        metadata.set(TikaCoreProperties.LANGUAGE, 
m.group(1).strip());
+                    }
+                    return;
+                case SKIP:
+                    return;
+                case NOTE:
+                    text(line);
+                    return;
+                default:
+                    break;
+            }
+            Matcher timing = timing(line);
+            if (timing != null) {
+                closeDiv();
+                block = Block.CUE;
+                pendingCue = new AttributesImpl();
+                pendingCue.addAttribute("", "class", "class", "CDATA", "cue");
+                addTime("data-start", timing.group(1));
+                if (timing.group(2) != null) {
+                    addTime("data-end", timing.group(2));
+                }
+                if (pendingId != null) {
+                    pendingCue.addAttribute("", "data-id", "data-id", "CDATA", 
pendingId);
+                    pendingId = null;
+                }
+            } else if (timing(next) != null && (vtt ? blockStart : 
isCueNumber(line))) {
+                // a cue identifier; any other line here is text missing its 
blank line
+                pendingId = line.strip();
+            } else {
+                text(line);
+            }
+        }
+
+        /** The {@link #TIMING} match for {@code s}, or null; cached for the 
last line asked. */
+        private Matcher timing(String s) {
+            if (s != timingLine) {
+                timingLine = s;
+                timingMatch = s != null && s.contains("-->") ? 
TIMING.matcher(s) : null;
+                if (timingMatch != null && !timingMatch.find()) {
+                    timingMatch = null;
+                }
+            }
+            return timingMatch;
+        }
+
+        private void addTime(String name, String raw) {
+            String t = normalizeTime(raw);
+            if (t != null) {
+                pendingCue.addAttribute("", name, name, "CDATA", t);
+            }
+        }
+
+        private static boolean isCueNumber(String line) {
+            String s = line.strip();
+            if (s.isEmpty()) {
+                return false;
+            }
+            for (int i = 0; i < s.length(); i++) {
+                if (!isDigit(s.charAt(i))) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        private static boolean startsWithKeyword(String line, String keyword) {
+            return line.startsWith(keyword) && (line.length() == 
keyword.length()
+                    || line.charAt(keyword.length()) == ' '
+                    || line.charAt(keyword.length()) == '\t');
+        }
+
+        private void text(String line) throws SAXException {
+            String cleaned = block == Block.NOTE ? line.strip() : clean(line, 
vtt).strip();
+            if (cleaned.isEmpty()) {
+                return;
+            }
+            if (!divOpen) {
+                if (block == Block.NOTE) {
+                    xhtml.startElement("div", "class", "note");
+                } else if (pendingCue != null) {
+                    xhtml.startElement("div", pendingCue);
+                } else {
+                    // text with no timing yet, e.g. a file that starts mid-cue
+                    xhtml.startElement("div", "class", "cue");
+                    block = Block.CUE;
+                }
+                pendingCue = null;
+                divOpen = true;
+            }
+            if (!pOpen) {
+                xhtml.startElement("p");
+                pOpen = true;
+            } else {
+                xhtml.characters("\n");
+            }
+            xhtml.characters(cleaned);
+        }
+
+        private void closeP() throws SAXException {
+            if (pOpen) {
+                xhtml.endElement("p");
+                pOpen = false;
+            }
+        }
+
+        private void closeDiv() throws SAXException {
+            closeP();
+            if (divOpen) {
+                xhtml.endElement("div");
+                divOpen = false;
+            }
+            pendingCue = null;
+        }
+    }
+
+    /** Lines split on LF, CRLF or CR; a line over {@link #MAX_LINE_CHARS} 
comes back in pieces. */
+    static final class LineReader {
+        private final Reader reader;
+        private final char[] buf = new char[8192];
+        private int pos;
+        private int len;
+        // 1 after a CR, 2 after CR CR: CR CR LF is one break, a doubled CRLF 
conversion
+        private int crs;
+
+        LineReader(Reader reader) {
+            this.reader = reader;
+        }
+
+        String next() throws IOException {
+            StringBuilder sb = null;
+            while (true) {
+                if (pos == len) {
+                    len = reader.read(buf);
+                    pos = 0;
+                    if (len <= 0) {
+                        len = 0;
+                        if (crs == 2) {
+                            crs = 0;
+                            return "";
+                        }
+                        return sb == null ? null : sb.toString();
+                    }
+                }
+                char c = buf[pos++];
+                if (crs == 1) {
+                    crs = 0;
+                    if (c == '\n') {
+                        continue;
+                    }
+                    if (c == '\r') {
+                        crs = 2;
+                        continue;
+                    }
+                } else if (crs == 2) {
+                    crs = 0;
+                    if (c == '\n') {
+                        continue;
+                    }
+                    // the second CR ended an empty line; c starts the next one
+                    pos--;
+                    return "";
+                }
+                if (sb == null) {
+                    sb = new StringBuilder();
+                }
+                if (c == '\n') {
+                    return sb.toString();
+                }
+                if (c == '\r') {
+                    crs = 1;
+                    return sb.toString();
+                }
+                sb.append(c);
+                if (sb.length() >= MAX_LINE_CHARS) {
+                    return sb.toString();
+                }
+            }
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/test/java/org/apache/tika/parser/subtitle/SubtitleParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/test/java/org/apache/tika/parser/subtitle/SubtitleParserTest.java
new file mode 100644
index 0000000000..c0c26f3fc8
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/test/java/org/apache/tika/parser/subtitle/SubtitleParserTest.java
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.tika.parser.subtitle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+public class SubtitleParserTest extends TikaTest {
+
+    private static final Pattern PARAGRAPH = Pattern.compile("<p>(.*?)</p>", 
Pattern.DOTALL);
+
+    private XMLResult parse(String content, String name, Charset charset) 
throws Exception {
+        Metadata metadata = new Metadata();
+        if (name != null) {
+            metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, name);
+        }
+        try (TikaInputStream tis = 
TikaInputStream.get(content.getBytes(charset))) {
+            return getXML(tis, AUTO_DETECT_PARSER, metadata);
+        }
+    }
+
+    private static List<String> cues(XMLResult r) {
+        List<String> cues = new ArrayList<>();
+        Matcher m = PARAGRAPH.matcher(r.xml);
+        while (m.find()) {
+            cues.add(m.group(1));
+        }
+        return cues;
+    }
+
+    @Test
+    public void testWebVtt() throws Exception {
+        String vtt = "WEBVTT - a title\nKind: captions\nLanguage: de-DE\n\n"
+                + "STYLE\n::cue { color: red }\n\n"
+                + "REGION\nid:fred\n\n"
+                + "NOTE a comment\nspanning two lines\n\n"
+                + "intro\n00:00:01.000 --> 00:00:04.000 align:start\n"
+                + "<v Roger>Never drink <b>liquid</b> 
<c.loud>nitrogen</c>.\n\n"
+                + "00:05.000 --> 00:09.000\n- It will <00:06.000>perforate 
&amp; burn,\n"
+                + "- you &lt;3 &#x263A;\n";
+        XMLResult r = parse(vtt, "x.vtt", StandardCharsets.UTF_8);
+        assertEquals(List.of("a comment\nspanning two lines", "Never drink 
liquid nitrogen.",
+                "- It will perforate &amp; burn,\n- you &lt;3 ☺"), cues(r));
+        assertContains("<div class=\"note\"><p>a comment", r.xml);
+        assertContains("<div class=\"cue\" data-start=\"00:00:01.000\" 
data-end=\"00:00:04.000\""
+                + " data-id=\"intro\"><p>Never", r.xml);
+        assertContains("<div class=\"cue\" data-start=\"00:00:05.000\" 
data-end=\"00:00:09.000\">",
+                r.xml);
+        assertNotContained("color", r.xml);
+        assertNotContained("fred", r.xml);
+        assertNotContained("align", r.xml);
+        assertNotContained("captions", r.xml);
+        assertEquals("de-DE", r.metadata.get(TikaCoreProperties.LANGUAGE));
+        
assertTrue(r.metadata.get(HttpHeaders.CONTENT_TYPE).startsWith("text/vtt; 
charset="),
+                r.metadata.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testSubRip() throws Exception {
+        String srt = "1\n00:01:14,440 --> 00:01:15,760\n<i>On peut écrire</i> 
{\\an8}a < b\n\n"
+                + "2\n00:01:15,880 --> 00:01:19,520\n<font 
color=\"#ffffff\">deux</font>\nlignes\n";
+        XMLResult r = parse(srt, "x.srt", StandardCharsets.UTF_8);
+        assertEquals(List.of("On peut écrire a &lt; b", "deux\nlignes"), 
cues(r));
+        assertContains("<div class=\"cue\" data-start=\"00:01:14.440\" 
data-end=\"00:01:15.760\""
+                + " data-id=\"1\">", r.xml);
+        assertTrue(r.metadata.get(HttpHeaders.CONTENT_TYPE)
+                .startsWith("application/x-subrip; charset="));
+    }
+
+    @Test
+    public void testSubRipDefects() throws Exception {
+        // missing blank line before cue 2; blank line inside cue 3; dotted 
milliseconds
+        String srt = "1\n00:00:01,000 --> 00:00:02,000\nfirst\n"
+                + "2\n00:00:03,000 --> 00:00:04,000\nsecond\n\n"
+                + "3\n00:00:05.000 --> 00:00:06.000\nthird a\n\nthird b\n\n"
+                + "4\n00:00:07,000 --> 00:00:08,000\n1984\n";
+        XMLResult r = parse(srt, "x.srt", StandardCharsets.UTF_8);
+        assertEquals(List.of("first", "second", "third a", "third b", "1984"), 
cues(r));
+        assertContains("data-id=\"3\"><p>third a</p>\n<p>third b</p>\n</div>", 
r.xml);
+        assertContains("data-start=\"00:00:05.000\"", r.xml);
+        assertEquals(4, r.xml.split("<div class=\"cue\"", -1).length - 1);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"00:00:05.000|00:00:05.000", 
"00:05.5|00:00:05.500",
+            "1:02:03|01:02:03.000", "00:00:05:042|00:00:05.042", 
"123:00:01,000|123:00:01.000",
+            "00:61.000|"})
+    public void testNormalizeTime(String io) {
+        String[] parts = io.split("\\|", -1);
+        String expected = parts[1].isEmpty() ? null : parts[1];
+        assertEquals(expected, SubtitleParser.normalizeTime(parts[0]));
+    }
+
+    @Test
+    public void testCrCrLfKeepsCueIds() throws Exception {
+        String srt = "1\r\r\n00:00:01,000 --> 
00:00:02,000\r\r\none\r\r\n\r\r\n"
+                + "2\r\r\n00:00:03,000 --> 00:00:04,000\r\r\ntwo\r\r\n";
+        XMLResult r = parse(srt, "x.srt", StandardCharsets.UTF_8);
+        assertEquals(List.of("one", "two"), cues(r));
+        assertContains("data-id=\"2\"", r.xml);
+    }
+
+    @Test
+    public void testLineReaderBreaks() throws Exception {
+        // CR CR LF is one break; CR CR without LF still ends an empty line 
(old Mac endings)
+        assertEquals(List.of("a", "b", "", "", "c", ""), 
lines("a\r\r\nb\r\r\rc\r\r"));
+        assertEquals(List.of("a", "", "b"), lines("a\r\n\r\nb"));
+    }
+
+    private static List<String> lines(String s) throws IOException {
+        SubtitleParser.LineReader reader = new SubtitleParser.LineReader(new 
StringReader(s));
+        List<String> out = new ArrayList<>();
+        for (String l = reader.next(); l != null; l = reader.next()) {
+            out.add(l);
+        }
+        return out;
+    }
+
+    @Test
+    public void testSubRipUnknownTagPairs() {
+        assertEquals("hide closed pairs and show text",
+                SubtitleParser.clean("<x par=5>hide closed pairs</x> and 
<y><z>show</z> text</Y>",
+                        false));
+        assertEquals("<open>left alone, a < b", 
SubtitleParser.clean("<open>left alone, a < b",
+                false));
+    }
+
+    @Test
+    public void testCrossedTagPairs() {
+        assertEquals("x", SubtitleParser.clean("<p><q></p></q>x", false));
+        assertEquals("<a>y</c>", SubtitleParser.clean("<a>y</c>", false));
+    }
+
+    @Test
+    @Timeout(10)
+    public void testLongTagLineStaysLinear() {
+        int n = SubtitleParser.MAX_LINE_CHARS / 8;
+        String unmatched = "<a>".repeat(n) + "</c>".repeat(n);
+        assertEquals(unmatched, SubtitleParser.clean(unmatched, false));
+        assertEquals("text", SubtitleParser.clean("<a>".repeat(n) + "text" + 
"</a>".repeat(n),
+                false));
+    }
+
+    @Test
+    public void testVttCueRightAfterHeader() throws Exception {
+        String vtt = "WEBVTT\nLanguage: en\n00:00.000 --> 00:01.000\nHello\n\n"
+                + "00:02.000 --> 00:03.000\nBye\n";
+        XMLResult r = parse(vtt, "x.vtt", StandardCharsets.UTF_8);
+        assertEquals(List.of("Hello", "Bye"), cues(r));
+        assertEquals("en", r.metadata.get(TikaCoreProperties.LANGUAGE));
+    }
+
+    @Test
+    public void testTextBeforeTimingIsNotAnId() throws Exception {
+        String srt = "1\n00:00:01,000 --> 00:00:02,000\nHello\n"
+                + "00:00:03,000 --> 00:00:04,000\nBye\n";
+        XMLResult r = parse(srt, "x.srt", StandardCharsets.UTF_8);
+        assertEquals(List.of("Hello", "Bye"), cues(r));
+        assertNotContained("data-id=\"Hello\"", r.xml);
+
+        String vtt = "WEBVTT\n\n00:01.000 --> 00:02.000\nHello\n"
+                + "00:03.000 --> 00:04.000\nBye\n";
+        r = parse(vtt, "x.vtt", StandardCharsets.UTF_8);
+        assertEquals(List.of("Hello", "Bye"), cues(r));
+        assertNotContained("data-id", r.xml);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"\n", "\r\n", "\r", "\r\r\n"})
+    public void testLineEndingsAndMagic(String eol) throws Exception {
+        String srt = ("1\n00:00:01,000 --> 00:00:02,000\none\n\n"
+                + "2\n00:00:03,000 --> 00:00:04,000\ntwo\n").replace("\n", 
eol);
+        XMLResult r = parse(srt, null, StandardCharsets.UTF_8);
+        
assertTrue(r.metadata.get(HttpHeaders.CONTENT_TYPE).startsWith("application/x-subrip"),
+                r.metadata.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(List.of("one", "two"), cues(r));
+
+        String vtt = ("WEBVTT\n\n00:01.000 --> 
00:02.000\nuno\n").replace("\n", eol);
+        r = parse(vtt, null, StandardCharsets.UTF_8);
+        
assertTrue(r.metadata.get(HttpHeaders.CONTENT_TYPE).startsWith("text/vtt"),
+                r.metadata.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(List.of("uno"), cues(r));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"WEBVTT\n", "WEBVTT\r\n", "WEBVTT - a title\nKind: 
captions\n",
+            "WEBVTT\ttitle\r\n", "WEBVTT FILE\n"})
+    public void testVttMagic(String header) throws Exception {
+        String vtt = header + "\n00:01.000 --> 00:02.000\nuno\n";
+        for (Charset cs : List.of(StandardCharsets.UTF_8, 
StandardCharsets.UTF_16LE,
+                StandardCharsets.UTF_16BE)) {
+            for (String bom : List.of("", "")) {
+                if (bom.isEmpty() && cs != StandardCharsets.UTF_8) {
+                    continue;
+                }
+                XMLResult r = parse(bom + vtt, null, cs);
+                String ct = r.metadata.get(HttpHeaders.CONTENT_TYPE);
+                assertTrue(ct.startsWith("text/vtt"), cs + " bom=" + 
!bom.isEmpty() + ": " + ct);
+                assertEquals(List.of("uno"), cues(r), cs + " bom=" + 
!bom.isEmpty());
+            }
+        }
+    }
+
+    @Test
+    public void testNotVtt() throws Exception {
+        XMLResult r = parse("WEBVTTS are fun\n\n00:01.000 --> 
00:02.000\nuno\n", null,
+                StandardCharsets.UTF_8);
+        
assertTrue(r.metadata.get(HttpHeaders.CONTENT_TYPE).startsWith("text/plain"),
+                r.metadata.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testBomAndLegacyCharset() throws Exception {
+        String vtt = "\uFEFFWEBVTT\n\n00:01.000 --> 00:02.000\nnaïve café\n";
+        assertEquals(List.of("naïve café"), cues(parse(vtt, "x.vtt", 
StandardCharsets.UTF_8)));
+
+        String srt = "1\n00:00:01,000 --> 00:00:02,000\n"
+                + "Привет, как дела? Это проверка русских субтитров.\n\n"
+                + "2\n00:00:03,000 --> 00:00:04,000\nВсё хорошо, спасибо 
большое.\n";
+        XMLResult r = parse(srt, "x.srt", Charset.forName("windows-1251"));
+        assertEquals(List.of("Привет, как дела? Это проверка русских 
субтитров.",
+                "Всё хорошо, спасибо большое."), cues(r));
+    }
+
+    @Test
+    public void testLongLineIsSplitNotBuffered() throws Exception {
+        String longText = "a".repeat(SubtitleParser.MAX_LINE_CHARS * 3 + 5);
+        SubtitleParser.LineReader lines =
+                new SubtitleParser.LineReader(new StringReader(longText + 
"\nb"));
+        int total = 0;
+        String line;
+        int pieces = 0;
+        while ((line = lines.next()) != null && !line.equals("b")) {
+            assertTrue(line.length() <= SubtitleParser.MAX_LINE_CHARS);
+            total += line.length();
+            pieces++;
+        }
+        assertEquals(longText.length(), total);
+        assertEquals(4, pieces);
+        assertEquals("b", line);
+        assertNull(lines.next());
+    }
+
+    @Test
+    public void testNoCues() throws Exception {
+        XMLResult r = parse("WEBVTT\n\nSTYLE\n::cue {}\n", "x.vtt", 
StandardCharsets.UTF_8);
+        assertEquals(List.of(), cues(r));
+        assertNotContained("<div", r.xml);
+    }
+}

Reply via email to