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
& burn,\n"
+ + "- you <3 ☺\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 & burn,\n- you <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 < 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);
+ }
+}