pycaption 2.3.8__tar.gz → 2.3.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycaption-2.3.8/pycaption.egg-info → pycaption-2.3.9}/PKG-INFO +1 -1
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/parser.py +4 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/reader.py +7 -1
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/reader.py +16 -6
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/specialized_collections.py +31 -19
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/state_machines.py +48 -19
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/srt.py +20 -10
- {pycaption-2.3.8 → pycaption-2.3.9/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.8 → pycaption-2.3.9}/setup.py +1 -1
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/conftest.py +8 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/scc.py +117 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_base.py +82 -9
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_sami.py +20 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_scc.py +228 -3
- pycaption-2.3.9/tests/test_scc_conversion.py +275 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_srt.py +19 -0
- pycaption-2.3.8/tests/test_scc_conversion.py +0 -117
- {pycaption-2.3.8 → pycaption-2.3.9}/LICENSE +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/MANIFEST.in +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/README.rst +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/base.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/geometry.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/transcript.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/utils.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/reader.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/setup.cfg +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/mixins.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_dfxp.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_functions.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_geometry.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_webvtt.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_webvtt_conversion.py +0 -0
|
@@ -174,6 +174,10 @@ class SAMIParser(HTMLParser):
|
|
|
174
174
|
:rtype: str | None
|
|
175
175
|
"""
|
|
176
176
|
for attr, value in attrs:
|
|
177
|
+
if value is None:
|
|
178
|
+
# A valueless attribute (e.g. a bare ``class``) carries no
|
|
179
|
+
# language information; skip it rather than crashing.
|
|
180
|
+
continue
|
|
177
181
|
if attr.lower() == "lang":
|
|
178
182
|
return value[:2]
|
|
179
183
|
if attr.lower() == "class":
|
|
@@ -142,7 +142,13 @@ class SAMIReader(BaseReader):
|
|
|
142
142
|
raise CaptionReadTimingError(
|
|
143
143
|
f"Missing start time on the following line: {p.parent}."
|
|
144
144
|
)
|
|
145
|
-
|
|
145
|
+
try:
|
|
146
|
+
milliseconds = int(float(start_str))
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise CaptionReadTimingError(
|
|
149
|
+
f"Invalid start time {start_str!r} on the following "
|
|
150
|
+
f"line: {p.parent}."
|
|
151
|
+
)
|
|
146
152
|
start = milliseconds * 1000
|
|
147
153
|
|
|
148
154
|
self._backfill_end_times(captions, start)
|
|
@@ -100,6 +100,7 @@ from .constants import (
|
|
|
100
100
|
SPECIAL_CHARS,
|
|
101
101
|
)
|
|
102
102
|
from .specialized_collections import (
|
|
103
|
+
PUNCTUATION_PREFIXES,
|
|
103
104
|
CaptionCreator,
|
|
104
105
|
InstructionNodeCreator,
|
|
105
106
|
NotifyingDict,
|
|
@@ -302,23 +303,28 @@ class SCCReader(BaseReader):
|
|
|
302
303
|
word = word.strip()
|
|
303
304
|
if len(word) == 4:
|
|
304
305
|
# Look ahead for the next command, skipping the duplicate
|
|
305
|
-
# that SCC uses for error-correction (same word repeated)
|
|
306
|
+
# that SCC uses for error-correction (same word repeated), to
|
|
307
|
+
# tell whether it decodes to punctuation.
|
|
306
308
|
next_idx = idx + 1
|
|
307
309
|
if next_idx < len(word_list) and word_list[next_idx].strip() == word:
|
|
308
310
|
next_idx += 1
|
|
309
311
|
next_command = (
|
|
310
312
|
word_list[next_idx] if next_idx < len(word_list) else None
|
|
311
313
|
)
|
|
312
|
-
|
|
314
|
+
next_is_punctuation = (
|
|
315
|
+
next_command is not None
|
|
316
|
+
and next_command[:2] in PUNCTUATION_PREFIXES
|
|
317
|
+
)
|
|
318
|
+
self._translate_word(word=word, next_is_punctuation=next_is_punctuation)
|
|
313
319
|
|
|
314
|
-
def _translate_word(self, word,
|
|
320
|
+
def _translate_word(self, word, next_is_punctuation=False):
|
|
315
321
|
"""Dispatch a single 4-char hex word as command, special char, or text."""
|
|
316
322
|
if self._handle_double_command(word):
|
|
317
323
|
# count frames for timing
|
|
318
324
|
self.time_translator._frames += 1
|
|
319
325
|
return
|
|
320
326
|
if word in COMMANDS or self._is_pac_command(word):
|
|
321
|
-
self._translate_command(word=word,
|
|
327
|
+
self._translate_command(word=word, next_is_punctuation=next_is_punctuation)
|
|
322
328
|
|
|
323
329
|
# second, check if word is a special character
|
|
324
330
|
elif word in SPECIAL_CHARS:
|
|
@@ -392,7 +398,7 @@ class SCCReader(BaseReader):
|
|
|
392
398
|
|
|
393
399
|
_ROLL_UP_DEPTH = {"9425": 2, "9426": 3, "94a7": 4}
|
|
394
400
|
|
|
395
|
-
def _translate_command(self, word,
|
|
401
|
+
def _translate_command(self, word, next_is_punctuation=False):
|
|
396
402
|
"""Route a CEA-608 control command to the appropriate handler."""
|
|
397
403
|
if word == "9420":
|
|
398
404
|
self._cmd_pop_on()
|
|
@@ -409,7 +415,11 @@ class SCCReader(BaseReader):
|
|
|
409
415
|
elif word == "942c":
|
|
410
416
|
self._cmd_erase_displayed()
|
|
411
417
|
else:
|
|
412
|
-
self.buffer.interpret_command(
|
|
418
|
+
self.buffer.interpret_command(
|
|
419
|
+
command=word,
|
|
420
|
+
next_is_punctuation=next_is_punctuation,
|
|
421
|
+
is_paint_on=self.buffer_dict.active_key == "paint",
|
|
422
|
+
)
|
|
413
423
|
|
|
414
424
|
def _cmd_pop_on(self):
|
|
415
425
|
"""Handle Resume Caption Loading [RCL] - switch to pop-on mode."""
|
|
@@ -32,7 +32,7 @@ from .constants import (
|
|
|
32
32
|
PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
|
|
33
33
|
|
|
34
34
|
# First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
|
|
35
|
-
|
|
35
|
+
PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class PreCaption:
|
|
@@ -412,7 +412,7 @@ class InstructionNodeCreator:
|
|
|
412
412
|
# only remaining possibility is plain text
|
|
413
413
|
return "plaintext"
|
|
414
414
|
|
|
415
|
-
def interpret_command(self, command,
|
|
415
|
+
def interpret_command(self, command, next_is_punctuation=False, is_paint_on=False):
|
|
416
416
|
"""Given a command determines whether to turn italics on or off,
|
|
417
417
|
or to set the positioning
|
|
418
418
|
|
|
@@ -420,9 +420,15 @@ class InstructionNodeCreator:
|
|
|
420
420
|
|
|
421
421
|
:type command: str
|
|
422
422
|
or a PAC_TAB_OFFSET_COMMANDS
|
|
423
|
-
:type
|
|
423
|
+
:type next_is_punctuation: bool
|
|
424
|
+
:param next_is_punctuation: whether the command right after this one
|
|
425
|
+
decodes to punctuation (comma, period, etc.)
|
|
426
|
+
:type is_paint_on: bool
|
|
427
|
+
:param is_paint_on: whether this command is being interpreted while
|
|
428
|
+
in paint-on mode, where a row+1 jump paired with a large column
|
|
429
|
+
jump indicates an unrelated, independently-positioned region
|
|
424
430
|
"""
|
|
425
|
-
self._update_positioning(command)
|
|
431
|
+
self._update_positioning(command, is_paint_on)
|
|
426
432
|
|
|
427
433
|
if command == "94a1":
|
|
428
434
|
self.handle_backspace("94a1")
|
|
@@ -434,7 +440,7 @@ class InstructionNodeCreator:
|
|
|
434
440
|
self._handle_style_command(command)
|
|
435
441
|
|
|
436
442
|
if command in MID_ROW_CODES and command not in PAC_TAB_OFFSET_COMMANDS:
|
|
437
|
-
self._handle_mid_row_spacing(
|
|
443
|
+
self._handle_mid_row_spacing(next_is_punctuation)
|
|
438
444
|
|
|
439
445
|
def _handle_background_color(self):
|
|
440
446
|
"""Strip trailing space before a background color code (CEA-608 rule)."""
|
|
@@ -460,9 +466,7 @@ class InstructionNodeCreator:
|
|
|
460
466
|
if self.last_style is not None and self.last_style != "italics off":
|
|
461
467
|
return
|
|
462
468
|
self._emit_pending_breaks(position)
|
|
463
|
-
self._collection.append(
|
|
464
|
-
_InstructionNode.create_italics_style(position)
|
|
465
|
-
)
|
|
469
|
+
self._collection.append(_InstructionNode.create_italics_style(position))
|
|
466
470
|
self.last_style = "italics on"
|
|
467
471
|
|
|
468
472
|
def _close_italics(self, position):
|
|
@@ -482,24 +486,29 @@ class InstructionNodeCreator:
|
|
|
482
486
|
if not self._position_tracer.is_linebreak_required():
|
|
483
487
|
return
|
|
484
488
|
for _ in range(self._position_tracer._breaks_required):
|
|
485
|
-
self._collection.append(
|
|
486
|
-
_InstructionNode.create_break(position=position)
|
|
487
|
-
)
|
|
489
|
+
self._collection.append(_InstructionNode.create_break(position=position))
|
|
488
490
|
self._position_tracer.acknowledge_linebreak_consumed()
|
|
489
491
|
|
|
490
|
-
def _handle_mid_row_spacing(self,
|
|
492
|
+
def _handle_mid_row_spacing(self, next_is_punctuation):
|
|
491
493
|
"""Insert spacing around mid-row code style transitions."""
|
|
492
|
-
|
|
494
|
+
if self._position_tracer.is_repositioning_required():
|
|
495
|
+
# A repositioning is already pending with no text written at the
|
|
496
|
+
# current position — that position is about to be abandoned, so
|
|
497
|
+
# padding it with a decorative space would wrongly consume the
|
|
498
|
+
# pending repositioning before the real content arrives.
|
|
499
|
+
return
|
|
493
500
|
prev_text_node = self.get_previous_text_node()
|
|
494
501
|
if not prev_text_node:
|
|
495
502
|
return
|
|
496
503
|
prev_node_is_break = any(
|
|
497
504
|
x.is_explicit_break()
|
|
498
|
-
for x in self._collection[self._collection.index(prev_text_node):]
|
|
505
|
+
for x in self._collection[self._collection.index(prev_text_node) :]
|
|
499
506
|
)
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
if (
|
|
508
|
+
prev_node_is_break
|
|
509
|
+
or prev_text_node.text[-1].isspace()
|
|
510
|
+
or next_is_punctuation
|
|
511
|
+
):
|
|
503
512
|
return
|
|
504
513
|
|
|
505
514
|
if self.last_style == "italics off":
|
|
@@ -507,10 +516,11 @@ class InstructionNodeCreator:
|
|
|
507
516
|
else:
|
|
508
517
|
prev_text_node.text = prev_text_node.text + " "
|
|
509
518
|
|
|
510
|
-
def _update_positioning(self, command):
|
|
519
|
+
def _update_positioning(self, command, is_paint_on=False):
|
|
511
520
|
"""Sets the positioning information to use for the next nodes
|
|
512
521
|
|
|
513
522
|
:type command: str
|
|
523
|
+
:type is_paint_on: bool
|
|
514
524
|
"""
|
|
515
525
|
is_offset = False
|
|
516
526
|
if command in PAC_TAB_OFFSET_COMMANDS:
|
|
@@ -529,7 +539,9 @@ class InstructionNodeCreator:
|
|
|
529
539
|
offset_after_break = is_offset and self.has_break_before(self._collection)
|
|
530
540
|
if not offset_after_break:
|
|
531
541
|
# Tab offsets after line breaks will be ignored to avoid repositioning
|
|
532
|
-
self._position_tracer.update_positioning(
|
|
542
|
+
self._position_tracer.update_positioning(
|
|
543
|
+
positioning, column_jump_forces_reposition=is_paint_on
|
|
544
|
+
)
|
|
533
545
|
|
|
534
546
|
def __iter__(self):
|
|
535
547
|
return iter(_format_italics(self._collection))
|
|
@@ -25,17 +25,39 @@ class _PositioningTracker:
|
|
|
25
25
|
# next positioning is actually a Tab Offset
|
|
26
26
|
self._last_column = None
|
|
27
27
|
|
|
28
|
-
def update_positioning(self, positioning):
|
|
28
|
+
def update_positioning(self, positioning, column_jump_forces_reposition=False):
|
|
29
29
|
"""Being notified of a position change, updates the internal state,
|
|
30
30
|
to as to be able to tell if it was a trivial change (a simple line
|
|
31
31
|
break) or not.
|
|
32
32
|
|
|
33
33
|
Strategy:
|
|
34
|
-
-
|
|
35
|
-
|
|
34
|
+
- A jump to the very next row (row + 1): Use a single line break —
|
|
35
|
+
this is simple text wrapping onto the next line. Exception: if
|
|
36
|
+
paired with a column jump bigger than a tab offset in paint-on
|
|
37
|
+
mode (see ``column_jump_forces_reposition``), it's treated as a
|
|
38
|
+
repositioning instead, since paint-on is the only mode that can
|
|
39
|
+
display independent, simultaneously-timed regions one row apart.
|
|
40
|
+
- Any other row jump (a skipped row, or a jump backwards): Use
|
|
41
|
+
repositioning (creates new cue). A skipped row is not an
|
|
42
|
+
intentional blank line to preserve — CEA-608 PACs only declare an
|
|
43
|
+
absolute row position; nothing in the spec says a gap between two
|
|
44
|
+
rows must be rendered as a blank line downstream, and WebVTT/HTML
|
|
45
|
+
renderers draw a real, often background-filled line for each
|
|
46
|
+
break, which previously produced a visible black bar between two
|
|
47
|
+
lines of text that were never meant to have a gap between them.
|
|
36
48
|
|
|
37
49
|
:type positioning: tuple[int]
|
|
38
50
|
:param positioning: a tuple (row, col)
|
|
51
|
+
|
|
52
|
+
:type column_jump_forces_reposition: bool
|
|
53
|
+
:param column_jump_forces_reposition: when True, a row+1 jump
|
|
54
|
+
paired with a column jump larger than a tab offset (>3 columns)
|
|
55
|
+
is treated as a repositioning rather than a break. Only
|
|
56
|
+
paint-on mode can display independent, simultaneously-timed
|
|
57
|
+
regions at unrelated columns, so callers should only set this
|
|
58
|
+
for paint-on buffers — pop-on and roll-up buffers legitimately
|
|
59
|
+
use large column shifts between buffered/wrapped lines of the
|
|
60
|
+
same cue.
|
|
39
61
|
"""
|
|
40
62
|
current = self._positions[-1]
|
|
41
63
|
|
|
@@ -51,24 +73,31 @@ class _PositioningTracker:
|
|
|
51
73
|
new_row, new_col = positioning
|
|
52
74
|
is_tab_offset = new_row == row and col + 1 <= new_col <= col + 3
|
|
53
75
|
|
|
54
|
-
# Threshold for when to use breaks vs repositioning
|
|
55
|
-
# Jumps of 4+ rows will trigger repositioning instead of adding breaks
|
|
56
|
-
max_breaks_threshold = 3
|
|
57
|
-
|
|
58
76
|
# Handle row jumps
|
|
59
77
|
if new_row > row:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
is_large_column_jump = (
|
|
79
|
+
column_jump_forces_reposition and abs(new_col - col) > 3
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# A jump to the very next row: use a line break. But if a
|
|
83
|
+
# repositioning was already pending and unconsumed (no text was
|
|
84
|
+
# ever written at the current position), this row jump is
|
|
85
|
+
# continuing that same unresolved position change, not wrapping
|
|
86
|
+
# text — so it must stay a repositioning rather than become a
|
|
87
|
+
# break. Likewise, in paint-on mode, pairing the row+1 jump
|
|
88
|
+
# with a column jump bigger than a tab offset means the new PAC
|
|
89
|
+
# almost certainly targets an unrelated, independently-positioned
|
|
90
|
+
# region rather than wrapping the current text.
|
|
91
|
+
if (
|
|
92
|
+
new_row == row + 1
|
|
93
|
+
and not self._repositioning_required
|
|
94
|
+
and not is_large_column_jump
|
|
95
|
+
):
|
|
64
96
|
self._positions.append((new_row, col))
|
|
65
|
-
|
|
66
|
-
# Row N -> N+1: 1 break
|
|
67
|
-
# Row N -> N+2: 2 breaks (preserves 1 blank line)
|
|
68
|
-
# Row N -> N+3: 3 breaks (preserves 2 blank lines)
|
|
69
|
-
self._breaks_required = row_diff
|
|
97
|
+
self._breaks_required = 1
|
|
70
98
|
self._last_column = new_col
|
|
71
|
-
#
|
|
99
|
+
# A skipped row, a jump backwards, or a row+1 jump with a large
|
|
100
|
+
# paint-on column jump: use repositioning (new cue)
|
|
72
101
|
else:
|
|
73
102
|
# Reset position - this triggers repositioning
|
|
74
103
|
self._positions = [positioning]
|
|
@@ -165,7 +194,7 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
|
|
|
165
194
|
except CaptionReadSyntaxError:
|
|
166
195
|
return self.default
|
|
167
196
|
|
|
168
|
-
def update_positioning(self, positioning):
|
|
197
|
+
def update_positioning(self, positioning, column_jump_forces_reposition=False):
|
|
169
198
|
"""If called, sets this positioning as the default, then delegates
|
|
170
199
|
to the super class.
|
|
171
200
|
|
|
@@ -175,4 +204,4 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
|
|
|
175
204
|
if positioning:
|
|
176
205
|
self.default = positioning
|
|
177
206
|
|
|
178
|
-
super().update_positioning(positioning)
|
|
207
|
+
super().update_positioning(positioning, column_jump_forces_reposition)
|
|
@@ -6,7 +6,7 @@ from .base import (
|
|
|
6
6
|
BaseReader, BaseWriter, Caption, CaptionList, CaptionNode, CaptionSet,
|
|
7
7
|
merge_caption_list,
|
|
8
8
|
)
|
|
9
|
-
from .exceptions import CaptionReadNoCaptions
|
|
9
|
+
from .exceptions import CaptionReadNoCaptions, CaptionReadSyntaxError
|
|
10
10
|
from .geometry import HorizontalAlignmentEnum
|
|
11
11
|
|
|
12
12
|
|
|
@@ -21,6 +21,8 @@ class SRTReader(BaseReader):
|
|
|
21
21
|
"""
|
|
22
22
|
content = self._decode_content(content)
|
|
23
23
|
lines = content.splitlines()
|
|
24
|
+
if len(lines) < 2:
|
|
25
|
+
return False
|
|
24
26
|
if lines[0].isdigit() and "-->" in lines[1]:
|
|
25
27
|
return True
|
|
26
28
|
else:
|
|
@@ -47,6 +49,11 @@ class SRTReader(BaseReader):
|
|
|
47
49
|
|
|
48
50
|
end_line = self._find_text_line(start_line, lines)
|
|
49
51
|
|
|
52
|
+
if start_line + 1 >= len(lines) or "-->" not in lines[start_line + 1]:
|
|
53
|
+
raise CaptionReadSyntaxError(
|
|
54
|
+
"Missing or malformed timing line after caption number "
|
|
55
|
+
f"{lines[start_line]!r}."
|
|
56
|
+
)
|
|
50
57
|
timing = lines[start_line + 1].split("-->")
|
|
51
58
|
start = self._srttomicro(timing[0].strip(" \r\n"))
|
|
52
59
|
end = self._srttomicro(timing[1].strip(" \r\n"))
|
|
@@ -81,15 +88,18 @@ class SRTReader(BaseReader):
|
|
|
81
88
|
def _srttomicro(stamp):
|
|
82
89
|
"""Convert an SRT timestamp (HH:MM:SS,mmm) to microseconds."""
|
|
83
90
|
timesplit = stamp.split(":")
|
|
84
|
-
|
|
85
|
-
timesplit[2]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
try:
|
|
92
|
+
if "," not in timesplit[2]:
|
|
93
|
+
timesplit[2] += ",000"
|
|
94
|
+
secsplit = timesplit[2].split(",")
|
|
95
|
+
microseconds = (
|
|
96
|
+
int(timesplit[0]) * 3600000000
|
|
97
|
+
+ int(timesplit[1]) * 60000000
|
|
98
|
+
+ int(secsplit[0]) * 1000000
|
|
99
|
+
+ int(secsplit[1]) * 1000
|
|
100
|
+
)
|
|
101
|
+
except (IndexError, ValueError):
|
|
102
|
+
raise CaptionReadSyntaxError(f"Invalid SRT timestamp: {stamp!r}.")
|
|
93
103
|
|
|
94
104
|
return microseconds
|
|
95
105
|
|
|
@@ -117,10 +117,18 @@ from tests.fixtures.scc import ( # noqa: F401
|
|
|
117
117
|
sample_scc_multiple_positioning,
|
|
118
118
|
sample_scc_no_explicit_end_to_last_caption,
|
|
119
119
|
sample_scc_paint_on_edm,
|
|
120
|
+
sample_scc_paint_on_row_and_column_jump_in_one_pac,
|
|
121
|
+
sample_scc_paint_on_row_plus_one_large_column_jump,
|
|
120
122
|
sample_scc_pop_on,
|
|
123
|
+
sample_scc_pop_on_row_and_column_jump_in_one_pac,
|
|
124
|
+
sample_scc_pop_on_row_plus_one_large_column_jump,
|
|
121
125
|
sample_scc_produces_captions_with_start_and_end_time_the_same,
|
|
126
|
+
sample_scc_roll_up_row_and_column_jump_in_one_pac,
|
|
127
|
+
sample_scc_roll_up_row_plus_one_large_column_jump,
|
|
122
128
|
sample_scc_roll_up_ru2,
|
|
123
129
|
sample_scc_roll_up_ru3,
|
|
130
|
+
sample_scc_row_jump_with_pending_reposition,
|
|
131
|
+
sample_scc_row_skip_does_not_preserve_blank_line,
|
|
124
132
|
sample_scc_special_and_extended_characters,
|
|
125
133
|
sample_scc_tab_offset,
|
|
126
134
|
sample_scc_with_ampersand_character,
|
|
@@ -1,6 +1,123 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
# Real-world bytes reproducing the "phantom BREAK" bug: two independent,
|
|
5
|
+
# simultaneously-timed paint-on captions ("Always by her side" / "And
|
|
6
|
+
# Trini!") whose PAC sequence has a small row jump (13 -> 15) combined
|
|
7
|
+
# with a same-row column jump (col 4 -> 24) that was left unconsumed
|
|
8
|
+
# (no text written) before the row jump landed.
|
|
9
|
+
@pytest.fixture(scope="session")
|
|
10
|
+
def sample_scc_row_jump_with_pending_reposition():
|
|
11
|
+
return """\
|
|
12
|
+
Scenarist_SCC V1.0
|
|
13
|
+
|
|
14
|
+
00:00:21:11 9420 942f 94ae 9420 13f2 91ae 9137 20c1 ecf7 6179 7320 6279 2068 e5f2 2073 e964 e520 9137 137c 9723 9120 94f4 9723 c16e 6420 54f2 e96e e9a1
|
|
15
|
+
|
|
16
|
+
00:00:25:00 942c 942c 942f 942f
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Real-world-equivalent bytes reproducing the phantom BREAK bug in its most
|
|
21
|
+
# direct form: a single paint-on PAC jumps both row (13 -> 15, a skipped
|
|
22
|
+
# row) and column (28 -> 8, far beyond a tab offset) in one step, with no
|
|
23
|
+
# intervening column-only PAC and no pending unconsumed repositioning to
|
|
24
|
+
# key off of. The row skip alone is already enough to force a new cue; the
|
|
25
|
+
# column jump is incidental.
|
|
26
|
+
@pytest.fixture(scope="session")
|
|
27
|
+
def sample_scc_paint_on_row_and_column_jump_in_one_pac():
|
|
28
|
+
return """\
|
|
29
|
+
Scenarist_SCC V1.0
|
|
30
|
+
|
|
31
|
+
00:00:20:00 9429 9429 13fe 13fe c1c2 94f4 94f4 43c4 942c 942c
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Same row-skip PAC pattern as
|
|
36
|
+
# sample_scc_paint_on_row_and_column_jump_in_one_pac, but in pop-on mode
|
|
37
|
+
# (9420 instead of 9429). A skipped row forces a new cue regardless of
|
|
38
|
+
# buffer mode, so this must also split into two cues, not stay joined by
|
|
39
|
+
# BREAK nodes.
|
|
40
|
+
@pytest.fixture(scope="session")
|
|
41
|
+
def sample_scc_pop_on_row_and_column_jump_in_one_pac():
|
|
42
|
+
return """\
|
|
43
|
+
Scenarist_SCC V1.0
|
|
44
|
+
|
|
45
|
+
00:00:20:00 9420 9420 13fe 13fe c1c2 94f4 94f4 43c4 942c 942c 942f 942f
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Same row-skip PAC pattern again, but in roll-up mode (9425/RU2 instead of
|
|
50
|
+
# 9429). Same as pop-on: the skipped row alone forces a new cue, so this
|
|
51
|
+
# also splits into two cues rather than staying joined by BREAK nodes.
|
|
52
|
+
@pytest.fixture(scope="session")
|
|
53
|
+
def sample_scc_roll_up_row_and_column_jump_in_one_pac():
|
|
54
|
+
return """\
|
|
55
|
+
Scenarist_SCC V1.0
|
|
56
|
+
|
|
57
|
+
00:00:20:00 9425 9425 13fe 13fe c1c2 94f4 94f4 43c4 942c 942c
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Real-world-equivalent bytes: a single paint-on PAC jumps to the very next
|
|
62
|
+
# row (5 -> 6, not a skip) but combined with a large column jump (0 -> 20,
|
|
63
|
+
# far beyond a tab offset), with no intervening column-only PAC and no
|
|
64
|
+
# pending unconsumed repositioning to key off of. Unlike a simple text wrap
|
|
65
|
+
# onto the next line (which stays at roughly the same column), this pattern
|
|
66
|
+
# indicates a second, independently-positioned region drawn one row below
|
|
67
|
+
# the first — the same "two side-by-side captions in paint-on mode" bug
|
|
68
|
+
# class this branch fixes for skipped rows, but for a row+1 jump instead.
|
|
69
|
+
@pytest.fixture(scope="session")
|
|
70
|
+
def sample_scc_paint_on_row_plus_one_large_column_jump():
|
|
71
|
+
return """\
|
|
72
|
+
Scenarist_SCC V1.0
|
|
73
|
+
|
|
74
|
+
00:00:20:00 9429 9429 1540 1540 c1c2 157a 157a 43c4 942c 942c
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Same row+1-plus-large-column-jump PAC pattern as
|
|
79
|
+
# sample_scc_paint_on_row_plus_one_large_column_jump, but in pop-on mode
|
|
80
|
+
# (9420 instead of 9429). column_jump_forces_reposition is only passed as
|
|
81
|
+
# True for paint-on buffers, so pop-on's large column shift between the
|
|
82
|
+
# wrapped lines is treated as legitimate same-cue formatting rather than an
|
|
83
|
+
# independent region — this must stay a single caption joined by a BREAK
|
|
84
|
+
# node, even though the column jump is identical to the paint-on case.
|
|
85
|
+
@pytest.fixture(scope="session")
|
|
86
|
+
def sample_scc_pop_on_row_plus_one_large_column_jump():
|
|
87
|
+
return """\
|
|
88
|
+
Scenarist_SCC V1.0
|
|
89
|
+
|
|
90
|
+
00:00:20:00 9420 9420 1540 1540 c1c2 157a 157a 43c4 942c 942c 942f 942f
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Same row+1-plus-large-column-jump PAC pattern again, but in roll-up mode
|
|
95
|
+
# (9425/RU2 instead of 9429). Like pop-on, roll-up is exempt from the
|
|
96
|
+
# column-jump check, so this must also stay a single caption joined by a
|
|
97
|
+
# BREAK node rather than being split like the paint-on case.
|
|
98
|
+
@pytest.fixture(scope="session")
|
|
99
|
+
def sample_scc_roll_up_row_plus_one_large_column_jump():
|
|
100
|
+
return """\
|
|
101
|
+
Scenarist_SCC V1.0
|
|
102
|
+
|
|
103
|
+
00:00:20:00 9425 9425 1540 1540 c1c2 157a 157a 43c4 942c 942c
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Real-world bytes: a single pop-on cue whose text skips a row (row 1 -> 3)
|
|
108
|
+
# with no preceding same-row PAC and no large-enough column jump to be
|
|
109
|
+
# mistaken for an independent region. This must split into two independently-
|
|
110
|
+
# positioned cues with no BREAK node joining them, since a skipped row is
|
|
111
|
+
# not an intentional blank line to preserve in the rendered cue.
|
|
112
|
+
@pytest.fixture(scope="session")
|
|
113
|
+
def sample_scc_row_skip_does_not_preserve_blank_line():
|
|
114
|
+
return """\
|
|
115
|
+
Scenarist_SCC V1.0
|
|
116
|
+
|
|
117
|
+
00:00:20:00 9420 9420 9152 9152 9723 9723 c1c2 92d0 92d0 43c4 942c 942c 942f 942f
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
|
|
4
121
|
@pytest.fixture(scope="session")
|
|
5
122
|
def sample_scc_created_dfxp_with_wrongly_closing_spans():
|
|
6
123
|
return """\
|
|
@@ -6,7 +6,6 @@ from pycaption.exceptions import CaptionReadSyntaxError
|
|
|
6
6
|
from pycaption.scc.state_machines import _PositioningTracker
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
|
|
10
9
|
class TestCaptionReadError:
|
|
11
10
|
def test_str_includes_class_name_and_message(self):
|
|
12
11
|
err = CaptionReadError("bad data")
|
|
@@ -83,19 +82,23 @@ class TestPositioningTracker:
|
|
|
83
82
|
assert tracker._breaks_required == 1
|
|
84
83
|
assert not tracker.is_repositioning_required()
|
|
85
84
|
|
|
86
|
-
def
|
|
85
|
+
def test_two_row_jump_triggers_repositioning_not_breaks(self):
|
|
86
|
+
# Skips row 2 -> not a simple wrap onto the next line, so it must
|
|
87
|
+
# become a repositioning (new cue), not a break.
|
|
87
88
|
tracker = _PositioningTracker((1, 0))
|
|
88
89
|
tracker.update_positioning((3, 0))
|
|
89
90
|
|
|
90
|
-
assert tracker.
|
|
91
|
-
assert tracker.
|
|
91
|
+
assert not tracker.is_linebreak_required()
|
|
92
|
+
assert tracker.is_repositioning_required()
|
|
93
|
+
assert tracker._breaks_required == 0
|
|
92
94
|
|
|
93
|
-
def
|
|
95
|
+
def test_three_row_jump_triggers_repositioning_not_breaks(self):
|
|
94
96
|
tracker = _PositioningTracker((1, 0))
|
|
95
97
|
tracker.update_positioning((4, 0))
|
|
96
98
|
|
|
97
|
-
assert tracker.
|
|
98
|
-
assert tracker.
|
|
99
|
+
assert not tracker.is_linebreak_required()
|
|
100
|
+
assert tracker.is_repositioning_required()
|
|
101
|
+
assert tracker._breaks_required == 0
|
|
99
102
|
|
|
100
103
|
def test_four_row_jump_triggers_repositioning_not_breaks(self):
|
|
101
104
|
tracker = _PositioningTracker((1, 0))
|
|
@@ -107,8 +110,8 @@ class TestPositioningTracker:
|
|
|
107
110
|
|
|
108
111
|
def test_acknowledge_linebreak_consumed_resets_counter(self):
|
|
109
112
|
tracker = _PositioningTracker((1, 0))
|
|
110
|
-
tracker.update_positioning((
|
|
111
|
-
assert tracker._breaks_required ==
|
|
113
|
+
tracker.update_positioning((2, 0))
|
|
114
|
+
assert tracker._breaks_required == 1
|
|
112
115
|
|
|
113
116
|
tracker.acknowledge_linebreak_consumed()
|
|
114
117
|
|
|
@@ -122,6 +125,76 @@ class TestPositioningTracker:
|
|
|
122
125
|
|
|
123
126
|
assert not tracker.is_repositioning_required()
|
|
124
127
|
|
|
128
|
+
def test_pending_repositioning_is_preserved_across_small_row_jump(self):
|
|
129
|
+
tracker = _PositioningTracker((13, 4))
|
|
130
|
+
# Same-row column jump with no text written: leaves a repositioning
|
|
131
|
+
# pending and unconsumed.
|
|
132
|
+
tracker.update_positioning((13, 24))
|
|
133
|
+
assert tracker.is_repositioning_required()
|
|
134
|
+
|
|
135
|
+
# A small row jump on top of that pending, unconsumed repositioning
|
|
136
|
+
# must not be reinterpreted as a trivial line break.
|
|
137
|
+
tracker.update_positioning((15, 8))
|
|
138
|
+
|
|
139
|
+
assert tracker.is_repositioning_required()
|
|
140
|
+
assert not tracker.is_linebreak_required()
|
|
141
|
+
assert tracker._breaks_required == 0
|
|
142
|
+
|
|
143
|
+
def test_row_jump_without_pending_repositioning_still_creates_breaks(self):
|
|
144
|
+
tracker = _PositioningTracker((14, 10))
|
|
145
|
+
tracker.update_positioning((15, 1))
|
|
146
|
+
|
|
147
|
+
assert tracker.is_linebreak_required()
|
|
148
|
+
assert tracker._breaks_required == 1
|
|
149
|
+
assert not tracker.is_repositioning_required()
|
|
150
|
+
|
|
151
|
+
def test_paint_on_row_plus_one_with_large_column_jump_forces_reposition(self):
|
|
152
|
+
# A row+1 jump normally stays a break, but a large column jump
|
|
153
|
+
# alongside it in paint-on mode signals an unrelated, independently-
|
|
154
|
+
# positioned region one row below, so it must reposition instead.
|
|
155
|
+
tracker = _PositioningTracker((13, 0))
|
|
156
|
+
tracker.update_positioning((14, 20), column_jump_forces_reposition=True)
|
|
157
|
+
|
|
158
|
+
assert tracker.is_repositioning_required()
|
|
159
|
+
assert not tracker.is_linebreak_required()
|
|
160
|
+
|
|
161
|
+
def test_row_plus_one_with_large_column_jump_stays_break_outside_paint_on(self):
|
|
162
|
+
# The same large column jump must NOT force a repositioning when
|
|
163
|
+
# column_jump_forces_reposition is left at its default (pop-on and
|
|
164
|
+
# roll-up buffers legitimately use large column shifts between
|
|
165
|
+
# wrapped lines of the same cue).
|
|
166
|
+
tracker = _PositioningTracker((13, 0))
|
|
167
|
+
tracker.update_positioning((14, 20))
|
|
168
|
+
|
|
169
|
+
assert not tracker.is_repositioning_required()
|
|
170
|
+
assert tracker.is_linebreak_required()
|
|
171
|
+
|
|
172
|
+
def test_paint_on_column_jump_boundary_at_tab_offset_threshold(self):
|
|
173
|
+
"""A column jump of exactly 3 (matching the tab-offset threshold
|
|
174
|
+
used elsewhere) alongside a row+1 jump must stay a break; one
|
|
175
|
+
column beyond that, a jump of 4, must force a repositioning.
|
|
176
|
+
Checked in both the positive (rightward) and negative (leftward)
|
|
177
|
+
column directions, since the implementation compares by absolute
|
|
178
|
+
value.
|
|
179
|
+
"""
|
|
180
|
+
tracker = _PositioningTracker((13, 0))
|
|
181
|
+
tracker.update_positioning((14, 3), column_jump_forces_reposition=True)
|
|
182
|
+
assert not tracker.is_repositioning_required()
|
|
183
|
+
assert tracker.is_linebreak_required()
|
|
184
|
+
|
|
185
|
+
tracker = _PositioningTracker((13, 0))
|
|
186
|
+
tracker.update_positioning((14, 4), column_jump_forces_reposition=True)
|
|
187
|
+
assert tracker.is_repositioning_required()
|
|
188
|
+
|
|
189
|
+
tracker = _PositioningTracker((13, 10))
|
|
190
|
+
tracker.update_positioning((14, 7), column_jump_forces_reposition=True)
|
|
191
|
+
assert not tracker.is_repositioning_required()
|
|
192
|
+
assert tracker.is_linebreak_required()
|
|
193
|
+
|
|
194
|
+
tracker = _PositioningTracker((13, 10))
|
|
195
|
+
tracker.update_positioning((14, 6), column_jump_forces_reposition=True)
|
|
196
|
+
assert tracker.is_repositioning_required()
|
|
197
|
+
|
|
125
198
|
def test_reset_for_new_caption_clears_all_state(self):
|
|
126
199
|
tracker = _PositioningTracker((1, 0))
|
|
127
200
|
tracker.update_positioning((3, 0))
|