pycaption 2.3.7__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.7/pycaption.egg-info → pycaption-2.3.9}/PKG-INFO +1 -1
  2. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/writer.py +53 -3
  3. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/geometry.py +22 -0
  4. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/parser.py +4 -0
  5. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/reader.py +8 -2
  6. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/reader.py +16 -6
  7. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/specialized_collections.py +31 -19
  8. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/state_machines.py +48 -19
  9. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/srt.py +20 -10
  10. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/constants.py +11 -2
  11. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/reader.py +61 -8
  12. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/writer.py +12 -1
  13. {pycaption-2.3.7 → pycaption-2.3.9/pycaption.egg-info}/PKG-INFO +1 -1
  14. {pycaption-2.3.7 → pycaption-2.3.9}/setup.py +1 -1
  15. {pycaption-2.3.7 → pycaption-2.3.9}/tests/conftest.py +8 -0
  16. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/scc.py +117 -0
  17. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_base.py +82 -9
  18. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_dfxp.py +3 -0
  19. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_sami.py +36 -0
  20. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_scc.py +232 -3
  21. pycaption-2.3.9/tests/test_scc_conversion.py +275 -0
  22. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_srt.py +19 -0
  23. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_webvtt.py +299 -2
  24. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_webvtt_conversion.py +133 -0
  25. pycaption-2.3.7/tests/test_scc_conversion.py +0 -117
  26. {pycaption-2.3.7 → pycaption-2.3.9}/LICENSE +0 -0
  27. {pycaption-2.3.7 → pycaption-2.3.9}/MANIFEST.in +0 -0
  28. {pycaption-2.3.7 → pycaption-2.3.9}/README.rst +0 -0
  29. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/__init__.py +0 -0
  30. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/base.py +0 -0
  31. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/__init__.py +0 -0
  32. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/constants.py +0 -0
  33. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/extras.py +0 -0
  34. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/reader.py +0 -0
  35. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/exceptions.py +0 -0
  36. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/microdvd.py +0 -0
  37. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/__init__.py +0 -0
  38. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/constants.py +0 -0
  39. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/writer.py +0 -0
  40. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/__init__.py +0 -0
  41. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/constants.py +0 -0
  42. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/translator.py +0 -0
  43. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/writer.py +0 -0
  44. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/transcript.py +0 -0
  45. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/utils.py +0 -0
  46. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/__init__.py +0 -0
  47. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/SOURCES.txt +0 -0
  48. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/dependency_links.txt +0 -0
  49. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/requires.txt +0 -0
  50. {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/top_level.txt +0 -0
  51. {pycaption-2.3.7 → pycaption-2.3.9}/setup.cfg +0 -0
  52. {pycaption-2.3.7 → pycaption-2.3.9}/tests/__init__.py +0 -0
  53. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/__init__.py +0 -0
  54. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/dfxp.py +0 -0
  55. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/microdvd.py +0 -0
  56. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/sami.py +0 -0
  57. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/srt.py +0 -0
  58. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/translated_scc.py +0 -0
  59. {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/webvtt.py +0 -0
  60. {pycaption-2.3.7 → pycaption-2.3.9}/tests/mixins.py +0 -0
  61. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_bytes_input.py +0 -0
  62. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_dfxp_conversion.py +0 -0
  63. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_dfxp_extras.py +0 -0
  64. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_double_encoding.py +0 -0
  65. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_functions.py +0 -0
  66. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_geometry.py +0 -0
  67. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_microdvd.py +0 -0
  68. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_microdvd_conversion.py +0 -0
  69. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_sami_conversion.py +0 -0
  70. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_scc_translator.py +0 -0
  71. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_scc_writer.py +0 -0
  72. {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_srt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.7
3
+ Version: 2.3.9
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -4,13 +4,14 @@ Serializes pycaption CaptionSet objects into DFXP/TTML XML documents,
4
4
  including style elements, region-based positioning, and writing direction.
5
5
  """
6
6
 
7
+ import re
7
8
  from copy import deepcopy
8
9
  from xml.sax.saxutils import escape
9
10
 
10
11
  from bs4 import BeautifulSoup
11
12
 
12
13
  from ..base import BaseWriter, CaptionNode
13
- from ..geometry import HorizontalAlignmentEnum, WritingDirectionEnum
14
+ from ..geometry import HorizontalAlignmentEnum, LineAlignmentEnum, WritingDirectionEnum
14
15
  from .constants import (
15
16
  DFXP_ATTR_XML_ID,
16
17
  DFXP_ATTR_XML_LANG,
@@ -30,6 +31,12 @@ _WRITING_DIRECTION_TO_DFXP = {
30
31
  WritingDirectionEnum.VERTICAL_LR: "tblr",
31
32
  }
32
33
 
34
+ _LINE_ALIGNMENT_TO_DISPLAY_ALIGN = {
35
+ LineAlignmentEnum.START: "before",
36
+ LineAlignmentEnum.CENTER: "center",
37
+ LineAlignmentEnum.END: "after",
38
+ }
39
+
33
40
 
34
41
  class DFXPWriter(BaseWriter):
35
42
  """Converts a CaptionSet to DFXP/TTML format.
@@ -454,12 +461,42 @@ class RegionCreator:
454
461
  region.extract()
455
462
 
456
463
 
464
+ _CSS_LENGTH_RE = re.compile(r"^-?[\d.]+(?:px|em|%|pt|rem|c)?$")
465
+ _CSS_COLOR_RE = re.compile(r"^(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-zA-Z]{3,})$")
466
+
467
+
468
+ def _text_shadow_to_outline(value):
469
+ """Best-effort conversion of CSS text-shadow to tts:textOutline.
470
+
471
+ Extracts the color and a representative thickness from the first shadow
472
+ in the list. Returns "color thickness" or None if unparseable.
473
+ """
474
+ first_shadow = value.split(",")[0].strip()
475
+ tokens = first_shadow.split()
476
+ color = None
477
+ lengths = []
478
+ for token in tokens:
479
+ if _CSS_LENGTH_RE.match(token):
480
+ lengths.append(token)
481
+ elif _CSS_COLOR_RE.match(token):
482
+ color = token
483
+ if not lengths:
484
+ return None
485
+ if len(lengths) >= 3:
486
+ thickness = lengths[2]
487
+ else:
488
+ thickness = lengths[0]
489
+ if color:
490
+ return f"{color} {thickness}"
491
+ return thickness
492
+
493
+
457
494
  def _recreate_style(content, dfxp):
458
495
  """Convert an internal style dict to DFXP/TTS style attributes.
459
496
 
460
497
  Maps pycaption's internal keys (class, italics, bold, underline, color,
461
- background-color, font-family, font-size, text-align, display-align)
462
- to their tts: namespace equivalents.
498
+ background-color, font-family, font-size, text-shadow, text-align,
499
+ display-align) to their tts: namespace equivalents.
463
500
 
464
501
  :param content: internal style dictionary
465
502
  :type content: dict
@@ -487,10 +524,18 @@ def _recreate_style(content, dfxp):
487
524
  dfxp_style["tts:fontFamily"] = content["font-family"]
488
525
  if "font-size" in content:
489
526
  dfxp_style["tts:fontSize"] = content["font-size"]
527
+ if "text-shadow" in content:
528
+ outline = _text_shadow_to_outline(content["text-shadow"])
529
+ if outline:
530
+ dfxp_style["tts:textOutline"] = outline
490
531
  if "color" in content:
491
532
  dfxp_style["tts:color"] = content["color"]
492
533
  if "background-color" in content:
493
534
  dfxp_style["tts:backgroundColor"] = content["background-color"]
535
+ if "opacity" in content:
536
+ dfxp_style["tts:opacity"] = content["opacity"]
537
+ if "line-height" in content:
538
+ dfxp_style["tts:lineHeight"] = content["line-height"]
494
539
  if "display-align" in content:
495
540
  dfxp_style["tts:displayAlign"] = content["display-align"]
496
541
 
@@ -536,4 +581,9 @@ def _convert_layout_to_attributes(layout, fallback_alignment=None):
536
581
  if writing_mode:
537
582
  result["tts:writingMode"] = writing_mode
538
583
 
584
+ if layout.line_alignment:
585
+ display_align = _LINE_ALIGNMENT_TO_DISPLAY_ALIGN.get(layout.line_alignment)
586
+ if display_align:
587
+ result["tts:displayAlign"] = display_align
588
+
539
589
  return result
@@ -68,6 +68,15 @@ class PositionAlignmentEnum(Enum):
68
68
  LINE_RIGHT = "line-right"
69
69
 
70
70
 
71
+ class LineAlignmentEnum(Enum):
72
+ """WebVTT line alignment: how the cue box is positioned relative to
73
+ the line setting value (start, center, or end of the cue box)."""
74
+
75
+ START = "start"
76
+ CENTER = "center"
77
+ END = "end"
78
+
79
+
71
80
  class WritingDirectionEnum(Enum):
72
81
  """Specifies WebVTT writing direction (vertical cue setting)."""
73
82
 
@@ -609,6 +618,7 @@ class Layout:
609
618
  webvtt_positioning=None,
610
619
  writing_direction=None,
611
620
  position_alignment=None,
621
+ line_alignment=None,
612
622
  inherit_from=None,
613
623
  ):
614
624
  """
@@ -638,6 +648,10 @@ class Layout:
638
648
  :param position_alignment: Which edge of the cue box the position
639
649
  percentage anchors to (line-left, center, or line-right).
640
650
 
651
+ :type line_alignment: LineAlignmentEnum
652
+ :param line_alignment: How the cue box is vertically aligned relative
653
+ to the line position (start, center, or end).
654
+
641
655
  :type inherit_from: Layout
642
656
  :param inherit_from: A Layout with the positioning parameters to be
643
657
  used if not specified by the positioning arguments,
@@ -650,6 +664,7 @@ class Layout:
650
664
  self.webvtt_positioning = webvtt_positioning
651
665
  self.writing_direction = writing_direction
652
666
  self.position_alignment = position_alignment
667
+ self.line_alignment = line_alignment
653
668
 
654
669
  if inherit_from:
655
670
  for attr_name in [
@@ -659,6 +674,7 @@ class Layout:
659
674
  "alignment",
660
675
  "writing_direction",
661
676
  "position_alignment",
677
+ "line_alignment",
662
678
  ]:
663
679
  attr = getattr(self, attr_name)
664
680
  if not attr:
@@ -674,6 +690,7 @@ class Layout:
674
690
  self.webvtt_positioning,
675
691
  self.writing_direction,
676
692
  self.position_alignment,
693
+ self.line_alignment,
677
694
  )
678
695
  )
679
696
 
@@ -692,6 +709,7 @@ class Layout:
692
709
  None if not self.alignment else self.alignment.serialized(),
693
710
  self.writing_direction,
694
711
  self.position_alignment,
712
+ self.line_alignment,
695
713
  )
696
714
 
697
715
  def __eq__(self, other: object) -> bool:
@@ -704,6 +722,7 @@ class Layout:
704
722
  and self.alignment == other.alignment
705
723
  and self.writing_direction == other.writing_direction
706
724
  and self.position_alignment == other.position_alignment
725
+ and self.line_alignment == other.line_alignment
707
726
  )
708
727
 
709
728
  def __hash__(self):
@@ -714,6 +733,7 @@ class Layout:
714
733
  + hash(self.alignment) * 5
715
734
  + hash(self.writing_direction) * 19
716
735
  + hash(self.position_alignment) * 23
736
+ + hash(self.line_alignment) * 29
717
737
  + 17
718
738
  )
719
739
 
@@ -735,6 +755,7 @@ class Layout:
735
755
  "alignment": self.alignment,
736
756
  "writing_direction": self.writing_direction,
737
757
  "position_alignment": self.position_alignment,
758
+ "line_alignment": self.line_alignment,
738
759
  }
739
760
  for attr_name in ["origin", "extent", "padding"]:
740
761
  attr = getattr(self, attr_name)
@@ -786,6 +807,7 @@ class Layout:
786
807
  padding=self.padding,
787
808
  alignment=self.alignment,
788
809
  writing_direction=self.writing_direction,
810
+ line_alignment=self.line_alignment,
789
811
  )
790
812
 
791
813
  def _resolve_position_alignment(self):
@@ -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)
@@ -263,7 +269,7 @@ class SAMIReader(BaseReader):
263
269
  for style in styles:
264
270
  style = style.split(":")
265
271
  if len(style) == 2:
266
- css_property, value = style
272
+ css_property, value = style[0].strip(), style[1]
267
273
  else:
268
274
  continue
269
275
  if css_property == "text-align":
@@ -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
 
@@ -6,7 +6,7 @@ docstrings below each pattern explain what they capture and give examples.
6
6
 
7
7
  import re
8
8
 
9
- from ..geometry import HorizontalAlignmentEnum, PositionAlignmentEnum
9
+ from ..geometry import HorizontalAlignmentEnum, LineAlignmentEnum, PositionAlignmentEnum
10
10
 
11
11
  TIMING_LINE_PATTERN = re.compile(r"^(\S+)\s+-->\s+(\S+)(?:\s+(.*?))?\s*$")
12
12
  """
@@ -60,7 +60,9 @@ Matches individual cue settings from the timing line, including optional
60
60
  comma-separated alignment qualifiers per W3C WebVTT spec:
61
61
  position:50% position:50%,line-left line:80%,center align:center
62
62
  """
63
- STYLE_SELECTOR_PATTERN = re.compile(r"::cue(?:\((\.?[\w-]+)\))?\s*\{([^}]*)\}")
63
+ STYLE_SELECTOR_PATTERN = re.compile(
64
+ r"::cue(?:\((\.[\w-]+(?:\.[\w-]+)*|[\w-]+)\))?\s*\{([^}]*)\}"
65
+ )
64
66
  """
65
67
  Matches ::cue selectors with their declaration blocks:
66
68
  ::cue { color: white } -> group(1)=None
@@ -111,6 +113,13 @@ POSITION_ALIGN_MAP = {
111
113
  }
112
114
  """Maps WebVTT positionAlign qualifier strings to PositionAlignmentEnum."""
113
115
 
116
+ LINE_ALIGN_MAP = {
117
+ "start": LineAlignmentEnum.START,
118
+ "center": LineAlignmentEnum.CENTER,
119
+ "end": LineAlignmentEnum.END,
120
+ }
121
+ """Maps WebVTT lineAlign qualifier strings to LineAlignmentEnum."""
122
+
114
123
 
115
124
  def _is_note_start(line):
116
125
  """Return True if the line begins a WebVTT NOTE block."""