Skip to content

Commit d77ab84

Browse files
authored
Accept non-padded timestamps in WebVTT cue timings (#99)
* Accept non-padded timestamps in WebVTT cue timings Closes #44 * Skip out of range VTT timestamps and tighten strict cue timings
1 parent 5dda403 commit d77ab84

7 files changed

Lines changed: 207 additions & 19 deletions

File tree

‎docs/source/usage.rst‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,20 @@ are part of the cue text, and a line containing ``-->`` starts a new
461461
block when it is not the timings line of the current one. Cues without
462462
text are kept as captions with empty text.
463463

464+
Timestamps with fields shorter than the specification requires, such as
465+
``0:0:13.540`` in Microsoft Teams transcripts or ``0:1:5.2``, are accepted
466+
and the captions are saved in the standard ``00:00:13.540`` and
467+
``00:01:05.200`` format. The fraction of a second is padded on the right,
468+
so ``.2`` is read as 200 milliseconds.
469+
464470
By default the parser ignores blocks it cannot parse, so a cue with
465-
malformed timings such as ``00:00:00.-004 --> 00:00:04.300`` is skipped.
471+
malformed timings such as ``00:00:00.-004 --> 00:00:04.300`` or out of
472+
range ones such as ``00:00:61.000 --> 00:00:62.000`` is skipped.
466473
To validate the content, pass ``strict=True`` to ``read``,
467474
``from_buffer`` or ``from_string`` and a ``MalformedCaptionError`` is
468-
raised instead. Strict parsing also raises the error when a cue is not
469-
separated from the previous cue, comment or style by an empty line:
475+
raised instead. Strict parsing also raises the error for timestamps that
476+
are not in the WebVTT format and when a cue is not separated from the
477+
previous cue, comment or style by an empty line:
470478

471479
.. code-block:: python
472480
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
WEBVTT
2+
3+
6e2b1f0c-0d3a-4c1e-9a7b-2f5d8e1c4a90/12-0
4+
0:0:0.0 --> 0:0:3.540
5+
<v Jane Doe>Good morning everyone.</v>
6+
7+
6e2b1f0c-0d3a-4c1e-9a7b-2f5d8e1c4a90/17-0
8+
0:0:3.540 --> 0:0:7.23
9+
<v John Smith>Morning, let's get started.</v>
10+
11+
6e2b1f0c-0d3a-4c1e-9a7b-2f5d8e1c4a90/25-0
12+
0:1:5.2 --> 1:2:7.950
13+
<v Jane Doe>Thanks for joining.</v>

‎tests/test_models.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ def test_from_string_single_digits(self):
3131
self.assertEqual(timestamp.seconds, 7)
3232
self.assertEqual(timestamp.milliseconds, 670)
3333

34+
def test_from_string_short_fraction(self):
35+
timestamp = Timestamp.from_string('0:1:5.2')
36+
self.assertEqual(timestamp.hours, 0)
37+
self.assertEqual(timestamp.minutes, 1)
38+
self.assertEqual(timestamp.seconds, 5)
39+
self.assertEqual(timestamp.milliseconds, 200)
40+
self.assertEqual(str(timestamp), '00:01:05.200')
41+
self.assertEqual(Timestamp.from_string('00:12.54').milliseconds, 540)
42+
3443
def test_from_string_missing_hours(self):
3544
timestamp = Timestamp.from_string('24:11.670')
3645
self.assertEqual(timestamp.hours, 0)

‎tests/test_vtt.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,48 @@ def test_is_valid(self):
7272
vtt.WebVTTCueBlock.is_valid([timings, 'Caption #1'])
7373
)
7474

75+
def test_is_valid_non_padded_timestamps(self):
76+
lines = ['0:0:13.540 --> 0:0:17.85', 'Caption #1']
77+
self.assertTrue(vtt.WebVTTCueBlock.is_valid(lines))
78+
self.assertFalse(vtt.WebVTTCueBlock.is_valid(lines, strict=True))
79+
self.assertTrue(
80+
vtt.WebVTTCueBlock.is_valid(
81+
['00:13.540 --> 00:00:17.850', 'Caption #1'],
82+
strict=True
83+
)
84+
)
85+
86+
def test_is_valid_strict_hours(self):
87+
for timings in (
88+
'0:00:13.540 --> 00:00:17.850',
89+
'00:00:13.540 --> 0:00:17.850'
90+
):
91+
self.assertTrue(vtt.WebVTTCueBlock.is_valid([timings]))
92+
self.assertFalse(
93+
vtt.WebVTTCueBlock.is_valid([timings], strict=True)
94+
)
95+
self.assertTrue(
96+
vtt.WebVTTCueBlock.is_valid(
97+
['100:00:13.540 --> 100:00:17.850'],
98+
strict=True
99+
)
100+
)
101+
102+
def test_is_valid_strict_end_timestamp_boundary(self):
103+
for timings in (
104+
'00:00:13.540 --> 00:00:17.8505',
105+
'00:00:13.540 --> 00:00:17.850abc'
106+
):
107+
self.assertFalse(
108+
vtt.WebVTTCueBlock.is_valid([timings], strict=True)
109+
)
110+
self.assertTrue(
111+
vtt.WebVTTCueBlock.is_valid(
112+
['00:00:13.540 --> 00:00:17.850 align:start'],
113+
strict=True
114+
)
115+
)
116+
75117
def test_from_lines(self):
76118
cue_block = vtt.WebVTTCueBlock.from_lines(textwrap.dedent('''
77119
identifier
@@ -587,6 +629,68 @@ def test_parse_ignores_malformed_cue_block(self):
587629
self.assertEqual(len(output.captions), 1)
588630
self.assertEqual(output.captions[0].text, 'Caption text #2')
589631

632+
def test_parse_non_padded_timestamps(self):
633+
output = vtt.parse(
634+
textwrap.dedent('''
635+
WEBVTT
636+
637+
0:0:0.0 --> 0:0:3.540
638+
Caption text #1
639+
640+
0:0:3.540 --> 0:1:5.2
641+
Caption text #2
642+
''').strip().split('\n')
643+
)
644+
self.assertEqual(len(output.captions), 2)
645+
self.assertEqual(output.captions[0].start, '00:00:00.000')
646+
self.assertEqual(output.captions[0].end, '00:00:03.540')
647+
self.assertEqual(output.captions[1].start, '00:00:03.540')
648+
self.assertEqual(output.captions[1].end, '00:01:05.200')
649+
650+
def test_parse_strict_non_padded_timestamps(self):
651+
with self.assertRaises(MalformedCaptionError) as cm:
652+
vtt.parse(
653+
textwrap.dedent('''
654+
WEBVTT
655+
656+
00:00:00.000 --> 00:00:03.540
657+
Caption text #1
658+
659+
0:0:3.540 --> 0:0:7.230
660+
Caption text #2
661+
''').strip().split('\n'),
662+
strict=True
663+
)
664+
self.assertIn('0:0:3.540 --> 0:0:7.230', str(cm.exception))
665+
666+
def test_parse_ignores_out_of_range_timestamp(self):
667+
output = vtt.parse(
668+
textwrap.dedent('''
669+
WEBVTT
670+
671+
00:00:61.000 --> 00:00:62.000
672+
Caption text #1
673+
674+
00:00:04.300 --> 00:00:07.000
675+
Caption text #2
676+
''').strip().split('\n')
677+
)
678+
self.assertEqual(len(output.captions), 1)
679+
self.assertEqual(output.captions[0].text, 'Caption text #2')
680+
681+
def test_parse_strict_out_of_range_timestamp(self):
682+
with self.assertRaises(MalformedCaptionError) as cm:
683+
vtt.parse(
684+
textwrap.dedent('''
685+
WEBVTT
686+
687+
00:00:61.000 --> 00:00:62.000
688+
Caption text #1
689+
''').strip().split('\n'),
690+
strict=True
691+
)
692+
self.assertIn('00:00:61.000', str(cm.exception))
693+
590694
def test_parse_strict_malformed_cue_block(self):
591695
with self.assertRaises(MalformedCaptionError) as cm:
592696
vtt.parse(

‎tests/test_webvtt.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,32 @@ def test_parse_valid_file_strict(self):
753753
vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt', strict=True)
754754
self.assertEqual(len(vtt.captions), 16)
755755

756+
def test_parse_non_padded_timestamps(self):
757+
"""https://github.com/glut23/webvtt-py/issues/44"""
758+
vtt = webvtt.read(PATH_TO_SAMPLES / 'non_padded_timestamps.vtt')
759+
self.assertEqual(len(vtt.captions), 3)
760+
self.assertEqual(
761+
[(caption.start, caption.end) for caption in vtt.captions],
762+
[('00:00:00.000', '00:00:03.540'),
763+
('00:00:03.540', '00:00:07.230'),
764+
('00:01:05.200', '01:02:07.950')]
765+
)
766+
self.assertEqual(vtt.captions[1].voice, 'John Smith')
767+
self.assertEqual(vtt.captions[1].text, "Morning, let's get started.")
768+
self.assertEqual(vtt.captions[2].end_in_seconds, 3727)
769+
self.assertIn(
770+
'00:01:05.200 --> 01:02:07.950\n<v Jane Doe>Thanks for joining.',
771+
vtt.content
772+
)
773+
774+
def test_parse_non_padded_timestamps_strict(self):
775+
self.assertRaises(
776+
MalformedCaptionError,
777+
webvtt.read,
778+
PATH_TO_SAMPLES / 'non_padded_timestamps.vtt',
779+
strict=True
780+
)
781+
756782
def test_parse_get_caption_data(self):
757783
vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt')
758784
self.assertEqual(vtt.captions[0].start_in_seconds, 0)

‎webvtt/models.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
class Timestamp:
1010
"""Representation of a timestamp."""
1111

12-
PATTERN = re.compile(r'(?:(\d{1,}):)?(\d{1,2}):(\d{1,2})\.(\d{3})')
12+
PATTERN = re.compile(r'(?:(\d{1,}):)?(\d{1,2}):(\d{1,2})\.(\d{1,3})')
1313

1414
def __init__(
1515
self,
@@ -73,6 +73,10 @@ def from_string(cls, value: str) -> 'Timestamp':
7373
"""
7474
Return a `Timestamp` instance from a string value.
7575
76+
The fields can be shorter than the WebVTT format requires, as in
77+
``0:1:5.2``, and the fraction of a second is padded on the right so
78+
``.2`` is read as 200 milliseconds.
79+
7680
:param value: the timestamp in ``[hh:]mm:ss.mmm`` format
7781
:returns: a `Timestamp` instance
7882
@@ -88,7 +92,7 @@ def from_string(cls, value: str) -> 'Timestamp':
8892
hours = int(match.group(1) or 0)
8993
minutes = int(match.group(2))
9094
seconds = int(match.group(3))
91-
milliseconds = int(match.group(4))
95+
milliseconds = int(match.group(4).ljust(3, '0'))
9296

9397
if minutes > 59 or seconds > 59:
9498
raise MalformedCaptionError(f'Invalid timestamp {value!r}')

‎webvtt/vtt.py‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from .errors import MalformedFileError, MalformedCaptionError
88
from .models import Caption, Style
99

10+
TIMESTAMP = r'(?:\d+:)?\d{1,2}:\d{1,2}\.\d{1,3}'
11+
STRICT_TIMESTAMP = r'(?:\d{2,}:)?\d{2}:\d{2}\.\d{3}'
12+
1013

1114
@dataclass
1215
class ParserOutput:
@@ -41,9 +44,10 @@ class WebVTTCueBlock:
4144
"""Representation of a cue timing block."""
4245

4346
CUE_TIMINGS_PATTERN = re.compile(
44-
r'\s*((?:\d+:)?\d{2}:\d{2}\.\d{3})'
45-
r'\s*-->\s*'
46-
r'((?:\d+:)?\d{2}:\d{2}\.\d{3})'
47+
rf'\s*({TIMESTAMP})\s*-->\s*({TIMESTAMP})'
48+
)
49+
STRICT_CUE_TIMINGS_PATTERN = re.compile(
50+
rf'\s*({STRICT_TIMESTAMP})\s*-->\s*({STRICT_TIMESTAMP})(?=\s|$)'
4751
)
4852

4953
def __init__(
@@ -68,24 +72,33 @@ def __init__(
6872
@classmethod
6973
def is_valid(
7074
cls,
71-
lines: typing.Sequence[str]
75+
lines: typing.Sequence[str],
76+
strict: bool = False
7277
) -> bool:
7378
"""
7479
Validate the lines for a match of a cue time block.
7580
81+
Timestamps with fields shorter than the WebVTT format requires, as
82+
in ``0:0:13.540``, are accepted unless strict.
83+
7684
:param lines: the lines to be validated
85+
:param strict: only accept timestamps in the WebVTT format
7786
:returns: true for a matching cue time block
7887
"""
88+
pattern = (
89+
cls.STRICT_CUE_TIMINGS_PATTERN if strict
90+
else cls.CUE_TIMINGS_PATTERN
91+
)
7992
return bool(
8093
(
8194
len(lines) >= 1 and
82-
re.match(cls.CUE_TIMINGS_PATTERN, lines[0]) and
95+
re.match(pattern, lines[0]) and
8396
(len(lines) < 2 or "-->" not in lines[1])
8497
) or
8598
(
8699
len(lines) >= 2 and
87100
"-->" not in lines[0] and
88-
re.match(cls.CUE_TIMINGS_PATTERN, lines[1]) and
101+
re.match(pattern, lines[1]) and
89102
(len(lines) < 3 or "-->" not in lines[2])
90103
)
91104
)
@@ -356,7 +369,7 @@ def parse_items(
356369
comments: typing.List[WebVTTCommentBlock] = []
357370

358371
for block_lines in iter_blocks_of_lines(lines, strict):
359-
item = parse_item(block_lines)
372+
item = parse_item(block_lines, strict)
360373
if item:
361374
item.comments = [comment.text for comment in comments]
362375
comments = []
@@ -380,21 +393,32 @@ def parse_items(
380393

381394

382395
def parse_item(
383-
lines: typing.Sequence[str]
396+
lines: typing.Sequence[str],
397+
strict: bool = False
384398
) -> typing.Union[Caption, Style, None]:
385399
"""
386400
Parse an item from lines of text.
387401
388402
:param lines: lines of text
403+
:param strict: only accept cue timestamps in the WebVTT format and
404+
raise an error for cues with an invalid timestamp
389405
:returns: An item (Caption or Style) if found, otherwise None
406+
407+
:raises MalformedCaptionError: if strict and a cue has an invalid
408+
timestamp
390409
"""
391-
if WebVTTCueBlock.is_valid(lines):
410+
if WebVTTCueBlock.is_valid(lines, strict):
392411
cue_block = WebVTTCueBlock.from_lines(lines)
393-
return Caption(cue_block.start,
394-
cue_block.end,
395-
cue_block.payload,
396-
cue_block.identifier
397-
)
412+
try:
413+
return Caption(cue_block.start,
414+
cue_block.end,
415+
cue_block.payload,
416+
cue_block.identifier
417+
)
418+
except MalformedCaptionError:
419+
if strict:
420+
raise
421+
return None
398422

399423
if WebVTTStyleBlock.is_valid(lines):
400424
return Style(WebVTTStyleBlock.from_lines(lines).text)

0 commit comments

Comments
 (0)