fix: validate WebVTT and mixed-index numeric dialogue together
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user