fix: validate WebVTT and mixed-index numeric dialogue together

This commit is contained in:
Palash Debnath
2026-09-17 12:18:02 +05:30
4 changed files with 159 additions and 22 deletions
+2
View File
@@ -18,6 +18,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Preserve numeric subtitle dialogue while recognizing mixed indexed and unindexed cues (#2151) — thanks @shivsin25!
- Parse pasted WebVTT cues without treating mid-sentence timestamps as subtitle records (#2077) — thanks @kevin9327!
## [0.5.3] — 2026-09-17
+47 -21
View File
@@ -23,9 +23,7 @@ import re
from dataclasses import dataclass
# Captures: HH MM SS sep(`,` or `.`) ms (1-3 digits). The hours are optional:
# WebVTT allows `mm:ss.ttt`, and .vtt files reach this parser from the paste
# dialog (the yt-dlp caption parser in dub_pipeline already accepts them).
# Captures: HH MM SS sep(`,` or `.`) ms (1-3 digits)
_TS = r"(?:(\d{1,2}):)?([0-5]?\d):([0-5]?\d)[,.](\d{1,3})"
# Horizontal whitespace only — NEVER plain `\s`, which matches newlines.
# A timing line lives on ONE line, so `\s*` bought nothing but catastrophic
@@ -38,18 +36,39 @@ _H = r"[^\S\n]*"
# Whole timing line: `00:00:01,000 --> 00:00:04,500` plus optional trailing
# cue style hints (X1: Y1: ... ) we just throw away.
_TIMING_RE = re.compile(rf"^{_H}{_TS}{_H}-->{_H}{_TS}.*$", re.MULTILINE)
# A WebVTT file starts with this signature line.
_WEBVTT_RE = re.compile(r"WEBVTT(?:[ \t]|\n|$)")
# A blank (or whitespace-only) line.
_BLANK_LINE_RE = re.compile(r"\n[^\S\n]*\n")
def _ts_to_seconds(h: "str | None", m: str, s: str, ms: str) -> float:
def _ts_to_seconds(h: str, m: str, s: str, ms: str) -> float:
# Pad ms to 3 digits so "5" -> 0.005, "50" -> 0.050.
ms_padded = (ms + "000")[:3]
return int(h or 0) * 3600 + int(m) * 60 + int(s) + int(ms_padded) / 1000.0
def _is_index_line(line: str) -> bool:
"""True when `line` is a bare SubRip cue number.
Stricter than `str.isdigit()` on purpose: that also accepts non-ASCII
numerals (Arabic-Indic "١٩٩٩", Devanagari "२०२६", and the full-width
forms), which in a 646-language dubbing app are dialogue, never the
ASCII cue indices SubRip actually writes.
"""
stripped = line.strip()
return stripped.isascii() and stripped.isdigit()
def _uses_index_lines(text: str, first_timing_start: int) -> bool:
"""Initial numbering hint for lenient files without blank separators.
Later cue boundaries are also inspected: mixed indexed/unindexed files
must not leak index lines or delete numeric dialogue.
"""
head = text[:first_timing_start]
for line in reversed(head.split("\n")):
if line.strip():
return _is_index_line(line)
return False
@dataclass
class SrtParseResult:
segments: list[dict]
@@ -74,17 +93,17 @@ def parse_srt(content: str) -> SrtParseResult:
# Strip BOM and normalise line endings; many editors save SRTs as CRLF.
text = content.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
# In WebVTT a cue's text ends at the first blank line; what follows before
# the next timing line is that cue's identifier or a NOTE/STYLE block, not
# dialogue. SRT keeps its lenient handling of blank lines inside a cue.
is_webvtt = bool(_WEBVTT_RE.match(text.lstrip()))
is_webvtt = bool(re.match(r"WEBVTT(?:[ \t]|\n|$)", text.lstrip()))
raw: list[dict] = []
skipped = 0
# Find every timing line, slice the cue text from there to the next
# timing line (or end of file). This is robust to missing index
# numbers and to spec deviations in the blank-line separator.
matches = list(_TIMING_RE.finditer(text))
# Each body is sliced up to the NEXT timing line, which swallows that
# cue's index line. Only an indexed file has an index to give back, so
# decide that once here instead of guessing from each body.
indexed = bool(matches) and _uses_index_lines(text, matches[0].start())
for i, m in enumerate(matches):
try:
start = _ts_to_seconds(m.group(1), m.group(2), m.group(3), m.group(4))
@@ -96,15 +115,22 @@ def parse_srt(content: str) -> SrtParseResult:
skipped += 1
continue
body_start = m.end()
body_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[body_start:body_end].strip("\n")
has_next = i + 1 < len(matches)
body_end = matches[i + 1].start() if has_next else len(text)
body = text[body_start:body_end]
if is_webvtt:
body = _BLANK_LINE_RE.split(body, maxsplit=1)[0]
# Drop the trailing index number of the NEXT cue (which got eaten
# into our body) by trimming trailing digit-only lines.
lines = body.split("\n")
while lines and lines[-1].strip().isdigit():
lines.pop()
# The blank separator ends WebVTT dialogue; following identifiers,
# NOTE/STYLE blocks belong outside the cue, even when numeric.
body = re.split(r"\n[^\S\n]*\n", body.lstrip("\n"), maxsplit=1)[0]
# An index must directly precede the next timing line. A blank line
# AFTER a number instead marks that number as preceding dialogue.
marker = re.search(r"(?:^|\n)([ \t]*[0-9]+[ \t]*)\n?[ \t]*\Z", body) if has_next and not is_webvtt else None
if marker:
before = body[:marker.start(1)]
separated = bool(re.search(r"\n[ \t]*\n[ \t]*$", before))
if separated or indexed:
body = before
lines = body.strip("\n").split("\n")
cue_text = "\n".join(line.strip() for line in lines if line.strip())
if not cue_text:
skipped += 1
+2
View File
@@ -236,3 +236,5 @@ panels instead so neither editor becomes unusably small.
picks up the newly-installed module.
Paste translation accepts WebVTT files with hourless timestamps. Only timing records at line starts activate timestamp matching; timestamp-like text inside a sentence remains dialogue.
Subtitle import preserves numeric dialogue such as years and countdowns, including files mixing numbered and unnumbered cues. Cue numbers are removed only at identified cue boundaries.
+108 -1
View File
@@ -232,4 +232,111 @@ def test_paste_endpoint_returns_webvtt_cues():
json={"text": "WEBVTT\n\nintro\n00:01.000 --> 00:02.000\nHola\n\nNOTE x\n\n00:03.000 --> 00:04.000\nAdios\n"},
)
assert res.status_code == 200, res.text
assert [(c["start"], c["text"]) for c in res.json()["segments"]] == [(1.0, "Hola"), (3.0, "Adios")]
assert [(c["start"], c["text"]) for c in res.json()["segments"]] == [(1.0, "Hola"), (3.0, "Adios")]
def test_keeps_a_final_cue_that_is_only_a_number():
# Regression: cue bodies are sliced up to the next timing line, which
# swallows that cue's index, and the parser used to claw it back by
# popping *every* trailing digit-only line. The last cue has no next
# index to pop, so a closing "1999" was mistaken for one — the cue lost
# its only line and was dropped as empty. Numeric-only dialogue is
# everywhere in subtitles (a year, a score, a street number).
srt = """1
00:00:01,000 --> 00:00:02,000
The year was
2
00:00:03,000 --> 00:00:04,000
1999
"""
result = parse_srt(srt)
assert result.skipped_cues == 0
assert [s["text"] for s in result.segments] == ["The year was", "1999"]
def test_keeps_numeric_dialogue_in_an_index_less_file():
# An index-less export has no index lines to strip at all, so a cue
# ending in a number kept its text silently truncated — no skip counted,
# so the import reported itself as lossless while dropping a line.
srt = """00:00:01,000 --> 00:00:02,000
The answer is
42
00:00:03,000 --> 00:00:04,000
Next.
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["The answer is\n42", "Next."]
def test_keeps_numeric_text_when_the_blank_separator_is_missing():
# Off-spec file with no blank line between cue text and the next index:
# exactly one trailing index line may be reclaimed, never two.
srt = """1
00:00:01,000 --> 00:00:02,000
100
2
00:00:03,000 --> 00:00:04,000
Hi
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["100", "Hi"]
def test_keeps_a_multi_line_numeric_countdown():
# The old `while` loop popped digit lines until it hit a non-digit, so a
# "3 / 2 / 1" countdown cue was consumed line by line and then dropped.
srt = """1
00:00:01,000 --> 00:00:02,000
Ready?
2
00:00:03,000 --> 00:00:04,000
3
2
1
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["Ready?", "3\n2\n1"]
def test_non_ascii_numerals_are_dialogue_not_cue_indices():
# `str.isdigit()` is True for Arabic-Indic and Devanagari numerals, which
# SubRip never uses for indices but a 646-language dubbing app sees as
# dialogue constantly.
srt = """1
00:00:01,000 --> 00:00:02,000
١٩٩٩
2
00:00:03,000 --> 00:00:04,000
२०२६
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["١٩٩٩", "२०२६"]
def test_still_strips_the_index_line_swallowed_from_the_next_cue():
# The guard against over-correcting: a numeric-bodied cue followed by
# another must keep its own text and still not leak the next index.
srt = """1
00:00:01,000 --> 00:00:02,000
1999
2
00:00:03,000 --> 00:00:04,000
Next.
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["1999", "Next."]
@pytest.mark.parametrize("first_index", ["", "1\n"])
def test_mixed_indexed_and_unindexed_cues_preserve_numbers(first_index):
text = first_index + "00:00:01,000 --> 00:00:02,000\n42\n\n2\n00:00:03,000 --> 00:00:04,000\n1999\n\n00:00:05,000 --> 00:00:06,000\n3\n2\n1\n"
assert [x["text"] for x in parse_srt(text).segments] == ["42", "1999", "3\n2\n1"]
def test_webvtt_numeric_dialogue_is_not_a_cue_identifier():
text = "WEBVTT\n\n00:01.000 --> 00:02.000\n1999\n\nnext-id\n00:03.000 --> 00:04.000\n42\n"
assert [cue["text"] for cue in parse_srt(text).segments] == ["1999", "42"]