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.
Files changed (72) hide show
  1. {pycaption-2.3.8/pycaption.egg-info → pycaption-2.3.9}/PKG-INFO +1 -1
  2. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/parser.py +4 -0
  3. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/reader.py +7 -1
  4. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/reader.py +16 -6
  5. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/specialized_collections.py +31 -19
  6. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/state_machines.py +48 -19
  7. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/srt.py +20 -10
  8. {pycaption-2.3.8 → pycaption-2.3.9/pycaption.egg-info}/PKG-INFO +1 -1
  9. {pycaption-2.3.8 → pycaption-2.3.9}/setup.py +1 -1
  10. {pycaption-2.3.8 → pycaption-2.3.9}/tests/conftest.py +8 -0
  11. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/scc.py +117 -0
  12. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_base.py +82 -9
  13. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_sami.py +20 -0
  14. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_scc.py +228 -3
  15. pycaption-2.3.9/tests/test_scc_conversion.py +275 -0
  16. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_srt.py +19 -0
  17. pycaption-2.3.8/tests/test_scc_conversion.py +0 -117
  18. {pycaption-2.3.8 → pycaption-2.3.9}/LICENSE +0 -0
  19. {pycaption-2.3.8 → pycaption-2.3.9}/MANIFEST.in +0 -0
  20. {pycaption-2.3.8 → pycaption-2.3.9}/README.rst +0 -0
  21. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/__init__.py +0 -0
  22. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/base.py +0 -0
  23. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/__init__.py +0 -0
  24. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/constants.py +0 -0
  25. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/extras.py +0 -0
  26. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/reader.py +0 -0
  27. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/dfxp/writer.py +0 -0
  28. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/exceptions.py +0 -0
  29. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/geometry.py +0 -0
  30. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/microdvd.py +0 -0
  31. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/__init__.py +0 -0
  32. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/constants.py +0 -0
  33. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/sami/writer.py +0 -0
  34. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/__init__.py +0 -0
  35. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/constants.py +0 -0
  36. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/translator.py +0 -0
  37. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/scc/writer.py +0 -0
  38. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/transcript.py +0 -0
  39. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/utils.py +0 -0
  40. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/__init__.py +0 -0
  41. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/constants.py +0 -0
  42. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/reader.py +0 -0
  43. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption/webvtt/writer.py +0 -0
  44. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/SOURCES.txt +0 -0
  45. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/dependency_links.txt +0 -0
  46. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/requires.txt +0 -0
  47. {pycaption-2.3.8 → pycaption-2.3.9}/pycaption.egg-info/top_level.txt +0 -0
  48. {pycaption-2.3.8 → pycaption-2.3.9}/setup.cfg +0 -0
  49. {pycaption-2.3.8 → pycaption-2.3.9}/tests/__init__.py +0 -0
  50. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/__init__.py +0 -0
  51. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/dfxp.py +0 -0
  52. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/microdvd.py +0 -0
  53. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/sami.py +0 -0
  54. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/srt.py +0 -0
  55. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/translated_scc.py +0 -0
  56. {pycaption-2.3.8 → pycaption-2.3.9}/tests/fixtures/webvtt.py +0 -0
  57. {pycaption-2.3.8 → pycaption-2.3.9}/tests/mixins.py +0 -0
  58. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_bytes_input.py +0 -0
  59. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_dfxp.py +0 -0
  60. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_dfxp_conversion.py +0 -0
  61. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_dfxp_extras.py +0 -0
  62. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_double_encoding.py +0 -0
  63. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_functions.py +0 -0
  64. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_geometry.py +0 -0
  65. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_microdvd.py +0 -0
  66. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_microdvd_conversion.py +0 -0
  67. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_sami_conversion.py +0 -0
  68. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_scc_translator.py +0 -0
  69. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_scc_writer.py +0 -0
  70. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_srt_conversion.py +0 -0
  71. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_webvtt.py +0 -0
  72. {pycaption-2.3.8 → pycaption-2.3.9}/tests/test_webvtt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.8
3
+ Version: 2.3.9
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -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
- milliseconds = int(float(start_str))
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
- self._translate_word(word=word, next_command=next_command)
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, next_command=None):
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, next_command=next_command)
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, next_command=None):
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(command=word, next_command=next_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
- _PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
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, next_command=None):
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 next_command: the command that follows next
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(next_command)
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, next_command):
492
+ def _handle_mid_row_spacing(self, next_is_punctuation):
491
493
  """Insert spacing around mid-row code style transitions."""
492
- next_is_punctuation = next_command and next_command[:2] in _PUNCTUATION_PREFIXES
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 (prev_node_is_break
501
- or prev_text_node.text[-1].isspace()
502
- or next_is_punctuation):
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(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
- - Small jumps (1-3 rows): Use line breaks to preserve visual spacing
35
- - Large jumps (4+ rows): Use repositioning (creates new cue)
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
- row_diff = new_row - row
61
-
62
- # Small jumps (1-3 rows): Use line breaks to preserve visual spacing
63
- if row_diff <= max_breaks_threshold:
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
- # Add breaks equal to row difference
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
- # Large jumps (4+ rows): Use repositioning (new cue)
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
- if "," not in timesplit[2]:
85
- timesplit[2] += ",000"
86
- secsplit = timesplit[2].split(",")
87
- microseconds = (
88
- int(timesplit[0]) * 3600000000
89
- + int(timesplit[1]) * 60000000
90
- + int(secsplit[0]) * 1000000
91
- + int(secsplit[1]) * 1000
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.8
3
+ Version: 2.3.9
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.10.0"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.3.8",
23
+ version="2.3.9",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -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 test_two_row_jump_creates_two_breaks(self):
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._breaks_required == 2
91
- assert tracker.is_linebreak_required()
91
+ assert not tracker.is_linebreak_required()
92
+ assert tracker.is_repositioning_required()
93
+ assert tracker._breaks_required == 0
92
94
 
93
- def test_three_row_jump_creates_three_breaks(self):
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._breaks_required == 3
98
- assert tracker.is_linebreak_required()
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((3, 0))
111
- assert tracker._breaks_required == 2
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))