pycaption 2.3.0__tar.gz → 2.3.2__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 (69) hide show
  1. {pycaption-2.3.0/pycaption.egg-info → pycaption-2.3.2}/PKG-INFO +1 -1
  2. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/base.py +107 -72
  3. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/constants.py +12 -0
  4. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/extras.py +8 -5
  5. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/reader.py +58 -41
  6. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/writer.py +70 -20
  7. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/geometry.py +127 -201
  8. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/microdvd.py +37 -23
  9. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/reader.py +13 -8
  10. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/writer.py +98 -23
  11. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/reader.py +83 -123
  12. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/specialized_collections.py +143 -194
  13. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/state_machines.py +4 -5
  14. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/writer.py +91 -82
  15. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/srt.py +17 -28
  16. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/transcript.py +6 -4
  17. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/reader.py +34 -33
  18. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/writer.py +32 -40
  19. {pycaption-2.3.0 → pycaption-2.3.2/pycaption.egg-info}/PKG-INFO +1 -1
  20. {pycaption-2.3.0 → pycaption-2.3.2}/setup.py +1 -1
  21. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/webvtt.py +4 -4
  22. {pycaption-2.3.0 → pycaption-2.3.2}/tests/mixins.py +11 -5
  23. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_dfxp_conversion.py +7 -1
  24. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_sami.py +1 -1
  25. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_sami_conversion.py +22 -3
  26. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc.py +8 -4
  27. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc_conversion.py +22 -11
  28. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_webvtt_conversion.py +10 -5
  29. {pycaption-2.3.0 → pycaption-2.3.2}/LICENSE +0 -0
  30. {pycaption-2.3.0 → pycaption-2.3.2}/MANIFEST.in +0 -0
  31. {pycaption-2.3.0 → pycaption-2.3.2}/README.rst +0 -0
  32. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/__init__.py +0 -0
  33. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/dfxp/__init__.py +0 -0
  34. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/exceptions.py +0 -0
  35. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/__init__.py +0 -0
  36. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/constants.py +0 -0
  37. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/sami/parser.py +0 -0
  38. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/__init__.py +0 -0
  39. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/constants.py +0 -0
  40. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/scc/translator.py +0 -0
  41. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/utils.py +0 -0
  42. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/__init__.py +0 -0
  43. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption/webvtt/constants.py +0 -0
  44. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/SOURCES.txt +0 -0
  45. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/dependency_links.txt +0 -0
  46. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/requires.txt +0 -0
  47. {pycaption-2.3.0 → pycaption-2.3.2}/pycaption.egg-info/top_level.txt +0 -0
  48. {pycaption-2.3.0 → pycaption-2.3.2}/setup.cfg +0 -0
  49. {pycaption-2.3.0 → pycaption-2.3.2}/tests/__init__.py +0 -0
  50. {pycaption-2.3.0 → pycaption-2.3.2}/tests/conftest.py +0 -0
  51. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/__init__.py +0 -0
  52. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/dfxp.py +0 -0
  53. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/microdvd.py +0 -0
  54. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/sami.py +0 -0
  55. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/scc.py +0 -0
  56. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/srt.py +0 -0
  57. {pycaption-2.3.0 → pycaption-2.3.2}/tests/fixtures/translated_scc.py +0 -0
  58. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_base.py +0 -0
  59. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_dfxp.py +0 -0
  60. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_dfxp_extras.py +0 -0
  61. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_functions.py +0 -0
  62. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_geometry.py +0 -0
  63. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_microdvd.py +0 -0
  64. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_microdvd_conversion.py +0 -0
  65. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc_translator.py +0 -0
  66. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_scc_writer.py +0 -0
  67. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_srt.py +0 -0
  68. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_srt_conversion.py +0 -0
  69. {pycaption-2.3.0 → pycaption-2.3.2}/tests/test_webvtt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -9,7 +9,7 @@ import os
9
9
  from datetime import timedelta
10
10
  from numbers import Number
11
11
 
12
- from .exceptions import CaptionReadError, CaptionReadTimingError
12
+ from .exceptions import CaptionReadError, CaptionReadTimingError, InvalidInputError
13
13
 
14
14
  # `und` a special identifier for an undetermined language according to ISO 639-2
15
15
  DEFAULT_LANGUAGE_CODE = os.getenv("PYCAPTION_DEFAULT_LANG", "und")
@@ -26,7 +26,7 @@ class CaptionConverter:
26
26
  """
27
27
 
28
28
  def __init__(self, captions=None):
29
- self.captions = captions if captions else []
29
+ self.captions = captions
30
30
 
31
31
  def read(self, content, caption_reader):
32
32
  """Parse caption content using the given reader.
@@ -35,10 +35,12 @@ class CaptionConverter:
35
35
  :param caption_reader: A BaseReader subclass instance.
36
36
  :returns: self (for chaining).
37
37
  """
38
- try:
39
- self.captions = caption_reader.read(content)
40
- except AttributeError as e:
41
- raise Exception(e)
38
+ if not hasattr(caption_reader, "read"):
39
+ raise InvalidInputError(
40
+ "The caption_reader must be a BaseReader instance "
41
+ "with a read() method."
42
+ )
43
+ self.captions = caption_reader.read(content)
42
44
  return self
43
45
 
44
46
  def write(self, caption_writer):
@@ -48,10 +50,12 @@ class CaptionConverter:
48
50
  :returns: The serialized caption string.
49
51
  :rtype: str
50
52
  """
51
- try:
52
- return caption_writer.write(self.captions)
53
- except AttributeError as e:
54
- raise Exception(e)
53
+ if not hasattr(caption_writer, "write"):
54
+ raise InvalidInputError(
55
+ "The caption_writer must be a BaseWriter instance "
56
+ "with a write() method."
57
+ )
58
+ return caption_writer.write(self.captions)
55
59
 
56
60
 
57
61
  class BaseReader:
@@ -107,6 +111,28 @@ class BaseWriter:
107
111
  self.video_height = video_height
108
112
  self.fit_to_screen = fit_to_screen
109
113
 
114
+ @staticmethod
115
+ def _get_visual_alignment_default(caption_set):
116
+ """Return the source format's visual alignment default from a CaptionSet.
117
+
118
+ Per SMPTE RP 2052-10, caption formats have different implicit visual
119
+ defaults when no alignment is specified:
120
+ - CENTER: WebVTT, SRT, SCC, MicroDVD
121
+ - LEFT/START: DFXP/TTML, SAMI
122
+
123
+ When converting between formats with mismatched defaults, the source
124
+ default must be made explicit in the output to prevent visual
125
+ regression (e.g. centered text silently becoming left-aligned).
126
+
127
+ Readers declare their default via CaptionSet.visual_alignment_default.
128
+ Writers compare it to their own format's default to decide whether
129
+ to emit explicit alignment.
130
+
131
+ :type caption_set: CaptionSet
132
+ :rtype: HorizontalAlignmentEnum | None
133
+ """
134
+ return caption_set.visual_alignment_default if caption_set else None
135
+
110
136
  def _relativize_and_fit_to_screen(self, layout_info):
111
137
  """Apply relativization and fit-to-screen adjustments to a Layout.
112
138
 
@@ -124,13 +150,14 @@ class BaseWriter:
124
150
  layout_info = layout_info.fit_to_screen()
125
151
  return layout_info
126
152
 
127
- def write(self, content):
153
+ def write(self, caption_set, **kwargs):
128
154
  """Serialize a CaptionSet. Subclasses override this.
129
155
 
130
- :type content: CaptionSet
156
+ :type caption_set: CaptionSet
157
+ :param kwargs: Format-specific options (e.g. force, lang).
131
158
  :rtype: str
132
159
  """
133
- return content
160
+ return caption_set
134
161
 
135
162
 
136
163
  class CaptionNode:
@@ -146,8 +173,6 @@ class CaptionNode:
146
173
  """
147
174
 
148
175
  TEXT = 1
149
- # When and if this is extended, it might be better to turn it into a
150
- # property of the node, not a type of node itself.
151
176
  STYLE = 2
152
177
  BREAK = 3
153
178
 
@@ -216,9 +241,9 @@ class Caption:
216
241
  """
217
242
  Initialize the Caption object
218
243
  :param start: The start time in microseconds
219
- :type start: Number
244
+ :type start: int
220
245
  :param end: The end time in microseconds
221
- :type end: Number
246
+ :type end: int
222
247
  :param nodes: A list of CaptionNodes
223
248
  :type nodes: list
224
249
  :param style: A dictionary with CSS-like styling rules
@@ -242,10 +267,8 @@ class Caption:
242
267
  self.nodes = nodes
243
268
  self.style = style or {}
244
269
  self.layout_info = layout_info
245
-
246
- def is_empty(self):
247
- """Return True if this caption has no nodes."""
248
- return not self.nodes
270
+ self.caption_mode = None
271
+ self.roll_up_rows = None
249
272
 
250
273
  def format_start(self, msec_separator=None):
251
274
  """Format start time as HH:MM:SS.mmm string.
@@ -273,33 +296,30 @@ class Caption:
273
296
 
274
297
  :rtype: list[str]
275
298
  """
276
- result = []
277
- for node in self.nodes:
278
- if node.type_ == CaptionNode.TEXT:
279
- result.append(node.content)
280
- elif node.type_ == CaptionNode.BREAK:
281
- result.append("\n")
282
- return result
299
+ return [
300
+ node.content if node.type_ == CaptionNode.TEXT else "\n"
301
+ for node in self.nodes
302
+ if node.type_ in (CaptionNode.TEXT, CaptionNode.BREAK)
303
+ ]
283
304
 
284
305
  def get_text(self):
285
306
  """Return the plain text content of this caption (no markup).
286
307
 
287
308
  :rtype: str
288
309
  """
289
- text_nodes = self.get_text_nodes()
290
- return "".join(text_nodes).strip()
310
+ return "".join(self.get_text_nodes()).strip()
291
311
 
292
- def _format_timestamp(self, microseconds, msec_separator=None):
312
+ @staticmethod
313
+ def _format_timestamp(microseconds, msec_separator=None):
293
314
  """Convert microseconds to HH:MM:SS{sep}mmm string."""
294
315
  duration = timedelta(microseconds=microseconds)
295
316
  hours, rem = divmod(duration.seconds, 3600)
296
317
  minutes, seconds = divmod(rem, 60)
297
- milliseconds = f"{duration.microseconds // 1000:03d}"
298
- timestamp = (
318
+ milliseconds = duration.microseconds // 1000
319
+ return (
299
320
  f"{hours:02d}:{minutes:02d}:{seconds:02d}"
300
- f"{msec_separator or '.'}{milliseconds:.3s}"
321
+ f"{msec_separator or '.'}{milliseconds:03d}"
301
322
  )
302
- return timestamp
303
323
 
304
324
 
305
325
  class CaptionList(list):
@@ -320,14 +340,14 @@ class CaptionList(list):
320
340
  return item
321
341
  return CaptionList(item, layout_info=self.layout_info)
322
342
 
323
- def __add__(self, other):
343
+ def __add__(self, value):
324
344
  add_is_safe = (
325
- not hasattr(other, "layout_info")
326
- or not other.layout_info
327
- or self.layout_info == other.layout_info
345
+ not hasattr(value, "layout_info")
346
+ or not value.layout_info
347
+ or self.layout_info == value.layout_info
328
348
  )
329
349
  if add_is_safe:
330
- return CaptionList(list.__add__(self, other), layout_info=self.layout_info)
350
+ return CaptionList(list.__add__(self, value), layout_info=self.layout_info)
331
351
  else:
332
352
  raise ValueError(
333
353
  "Cannot add CaptionList objects with different layout_info"
@@ -348,17 +368,27 @@ class CaptionSet:
348
368
  by all the children.
349
369
  """
350
370
 
351
- def __init__(self, captions, styles=None, layout_info=None, regions=None):
371
+ def __init__(
372
+ self, captions, styles=None, layout_info=None, regions=None,
373
+ visual_alignment_default=None,
374
+ ):
352
375
  """
353
376
  :param captions: A dictionary of the format {'language': CaptionList}
354
377
  :param styles: A dictionary with CSS-like styling rules
355
378
  :param Layout layout_info: A Layout object with the positioning info
356
379
  :param regions: A dictionary mapping region id to raw settings dict
380
+ :param visual_alignment_default: The source format's implicit text
381
+ alignment when no explicit alignment is specified. Per SMPTE
382
+ RP 2052-10, writers targeting a format with a different visual
383
+ default must emit this alignment explicitly to prevent visual
384
+ regression. Use HorizontalAlignmentEnum values.
385
+ :type visual_alignment_default: HorizontalAlignmentEnum | None
357
386
  """
358
387
  self._captions = captions
359
388
  self._styles = styles or {}
360
389
  self._regions = regions or {}
361
390
  self.layout_info = layout_info
391
+ self.visual_alignment_default = visual_alignment_default
362
392
 
363
393
  def set_captions(self, lang, captions):
364
394
  """Replace the caption list for a given language.
@@ -429,7 +459,10 @@ class CaptionSet:
429
459
 
430
460
  def is_empty(self):
431
461
  """Return True if no language contains any captions."""
432
- return all([len(captions) == 0 for captions in list(self._captions.values())])
462
+ for captions in self._captions.values():
463
+ if len(captions) > 0:
464
+ return False
465
+ return True
433
466
 
434
467
  def set_layout_info(self, lang, layout_info):
435
468
  """Set the layout_info on the CaptionList for a given language.
@@ -469,44 +502,46 @@ class CaptionSet:
469
502
  self.set_captions(lang, out_captions)
470
503
 
471
504
 
472
- # Functions
473
505
  def merge_concurrent_captions(caption_set):
474
506
  """Merge captions that have the same start and end times"""
475
507
  for lang in caption_set.get_languages():
476
508
  captions = caption_set.get_captions(lang)
477
- last_caption = None
478
- concurrent_captions = CaptionList()
479
- merged_captions = CaptionList()
480
- for caption in captions:
481
- if last_caption:
482
- last_timespan = last_caption.start, last_caption.end
483
- current_timespan = caption.start, caption.end
484
- if current_timespan == last_timespan:
485
- concurrent_captions.append(caption)
486
- last_caption = caption
487
- continue
488
- else:
489
- merged_captions.append(merge(concurrent_captions))
490
- concurrent_captions = [caption]
491
- last_caption = caption
492
-
493
- if concurrent_captions:
494
- merged_captions.append(merge(concurrent_captions))
495
- if merged_captions:
496
- caption_set.set_captions(lang, merged_captions)
509
+ merged = merge_caption_list(captions)
510
+ if merged:
511
+ caption_set.set_captions(lang, merged)
497
512
  return caption_set
498
513
 
499
514
 
500
- def merge(captions):
501
- """
502
- Merge list of captions into one caption. The start/end times from the first
503
- caption are kept.
504
- """
515
+ def merge_caption_list(captions):
516
+ """Merge consecutive captions with identical start/end times into one."""
517
+ if not captions:
518
+ return CaptionList()
519
+ last_caption = None
520
+ concurrent_captions = CaptionList()
521
+ merged_captions = CaptionList()
522
+ for caption in captions:
523
+ if last_caption:
524
+ last_timespan = last_caption.start, last_caption.end
525
+ current_timespan = caption.start, caption.end
526
+ if current_timespan == last_timespan:
527
+ concurrent_captions.append(caption)
528
+ last_caption = caption
529
+ continue
530
+ else:
531
+ merged_captions.append(_merge_group(concurrent_captions))
532
+ concurrent_captions = [caption]
533
+ last_caption = caption
534
+
535
+ if concurrent_captions:
536
+ merged_captions.append(_merge_group(concurrent_captions))
537
+ return merged_captions
538
+
539
+
540
+ def _merge_group(captions):
541
+ """Merge a group of captions into one, keeping the first caption's timing."""
505
542
  new_nodes = []
506
543
  for caption in captions:
507
544
  if new_nodes:
508
545
  new_nodes.append(CaptionNode.create_break())
509
- for node in caption.nodes:
510
- new_nodes.append(node)
511
- caption = Caption(captions[0].start, captions[0].end, new_nodes, captions[0].style)
512
- return caption
546
+ new_nodes.extend(caption.nodes)
547
+ return Caption(captions[0].start, captions[0].end, new_nodes, captions[0].style)
@@ -25,10 +25,22 @@ DFXP_DEFAULT_STYLE = {
25
25
  "font-size": "1c",
26
26
  }
27
27
 
28
+ # Reader default: DFXP spec mandates START/BOTTOM for round-trip fidelity.
28
29
  DFXP_DEFAULT_REGION = Layout(
29
30
  alignment=Alignment(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM)
30
31
  )
31
32
 
33
+ # Writer fallback alignment used when layout is None or SCC positional.
34
+ DFXP_WRITER_FALLBACK_ALIGNMENT = Alignment(
35
+ HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM
36
+ )
37
+
38
+ # Writer default region for sources without positioning (VTT/SRT);
39
+ # uses CENTER per RP 2052-10 rather than the spec's START default.
40
+ DFXP_WRITER_DEFAULT_REGION = Layout(
41
+ alignment=Alignment(HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM)
42
+ )
43
+
32
44
  DFXP_DEFAULT_STYLE_ID = "default"
33
45
  DFXP_DEFAULT_REGION_ID = "bottom"
34
46
 
@@ -46,18 +46,19 @@ class SinglePositioningDFXPWriter(DFXPWriter):
46
46
  super().__init__(*args, **kwargs)
47
47
  self.default_positioning = default_positioning
48
48
 
49
- def write(self, captions_set, force=""):
49
+ def write(self, captions_set, **kwargs):
50
50
  """Writes a DFXP file using the positioning provided in the initializer
51
51
 
52
52
  :type captions_set: pycaption.base.CaptionSet
53
- :param force: only write this language, if available in the CaptionSet
53
+ :param kwargs:
54
+ force (str): only write this language, if available in the CaptionSet
54
55
  :rtype: str
55
56
  """
56
57
  captions_set = self._create_single_positioning_caption_set(
57
58
  captions_set, self.default_positioning
58
59
  )
59
60
 
60
- return super().write(captions_set, force) # noqa
61
+ return super().write(captions_set, **kwargs)
61
62
 
62
63
  @staticmethod
63
64
  def _create_single_positioning_caption_set(caption_set, positioning):
@@ -98,13 +99,15 @@ class LegacyDFXPWriter(BaseWriter):
98
99
  def __init__(self, *args, **kw):
99
100
  self.open_span = False
100
101
 
101
- def write(self, caption_set, force=""):
102
+ def write(self, caption_set, **kwargs):
102
103
  """Serialize a CaptionSet into legacy DFXP format.
103
104
 
104
105
  :type caption_set: CaptionSet
105
- :param force: if set, output only this language (falls back to last)
106
+ :param kwargs:
107
+ force (str): if set, output only this language (falls back to last)
106
108
  :rtype: str
107
109
  """
110
+ force = kwargs.get("force", "")
108
111
  caption_set = deepcopy(caption_set)
109
112
  caption_set = merge_concurrent_captions(caption_set)
110
113
 
@@ -25,6 +25,7 @@ from ..exceptions import (
25
25
  )
26
26
  from ..geometry import (
27
27
  Alignment,
28
+ HorizontalAlignmentEnum,
28
29
  Layout,
29
30
  Padding,
30
31
  Point,
@@ -129,44 +130,7 @@ class DFXPReader(BaseReader):
129
130
  )
130
131
 
131
132
  tt_attrs = dfxp_document.tt.attrs if dfxp_document.tt else {}
132
- framerate_str = tt_attrs.get("ttp:framerate", str(DFXP_DEFAULT_FRAMERATE))
133
- multiplier_str = tt_attrs.get(
134
- "ttp:frameratemultiplier", DFXP_DEFAULT_FRAMERATE_MULTIPLIER
135
- )
136
- self.framerate = self._get_effective_framerate(framerate_str, multiplier_str)
137
-
138
- if "ttp:tickrate" in tt_attrs:
139
- try:
140
- tickrate = float(tt_attrs["ttp:tickrate"])
141
- except ValueError:
142
- raise CaptionReadSyntaxError(
143
- f"ttp:tickRate must be a number, "
144
- f"got '{tt_attrs['ttp:tickrate']}'"
145
- )
146
- if tickrate <= 0:
147
- raise CaptionReadSyntaxError(
148
- f"ttp:tickRate must be positive, got '{tt_attrs['ttp:tickrate']}'"
149
- )
150
- self.tickrate = tickrate
151
- else:
152
- # TTML spec 8.2.12: default tickRate = frameRate × subFrameRate
153
- try:
154
- sub_framerate = int(
155
- tt_attrs.get("ttp:subframerate", DFXP_DEFAULT_SUBFRAMERATE)
156
- )
157
- except ValueError:
158
- raise CaptionReadSyntaxError(
159
- f"ttp:subFrameRate must be a positive integer, "
160
- f"got '{tt_attrs['ttp:subframerate']}'"
161
- )
162
- try:
163
- framerate_int = int(framerate_str)
164
- except ValueError:
165
- raise CaptionReadSyntaxError(
166
- f"ttp:frameRate must be a positive integer, "
167
- f"got '{framerate_str}'"
168
- )
169
- self.tickrate = float(framerate_int * sub_framerate)
133
+ self._resolve_timing_parameters(tt_attrs)
170
134
 
171
135
  caption_dict = {}
172
136
  style_dict = {}
@@ -180,18 +144,71 @@ class DFXPReader(BaseReader):
180
144
  for style in dfxp_document.find_all("style"):
181
145
  id_ = style.attrs.get(DFXP_ATTR_XML_ID) or style.attrs.get("id")
182
146
  if id_:
183
- # Styles nested inside <region> tags are region-scoped and
184
- # should not appear as document-level styles.
185
147
  if "region" not in [parent_.name for parent_ in style.parents]:
186
148
  style_dict[id_] = self._convert_style(style)
187
149
 
188
- caption_set = CaptionSet(caption_dict, styles=style_dict)
150
+ caption_set = CaptionSet(
151
+ caption_dict, styles=style_dict,
152
+ visual_alignment_default=HorizontalAlignmentEnum.START,
153
+ )
189
154
 
190
155
  if caption_set.is_empty():
191
156
  raise CaptionReadNoCaptions("empty caption file")
192
157
 
193
158
  return caption_set
194
159
 
160
+ def _resolve_timing_parameters(self, tt_attrs):
161
+ """Extract framerate and tickrate from <tt> attributes.
162
+
163
+ Sets self.framerate and self.tickrate per TTML spec sections 8.2.8,
164
+ 8.2.11, and 8.2.12.
165
+ """
166
+ framerate_str = tt_attrs.get("ttp:framerate", str(DFXP_DEFAULT_FRAMERATE))
167
+ multiplier_str = tt_attrs.get(
168
+ "ttp:frameratemultiplier", DFXP_DEFAULT_FRAMERATE_MULTIPLIER
169
+ )
170
+ self.framerate = self._get_effective_framerate(framerate_str, multiplier_str)
171
+
172
+ if "ttp:tickrate" in tt_attrs:
173
+ self._resolve_explicit_tickrate(tt_attrs)
174
+ else:
175
+ self._resolve_default_tickrate(tt_attrs, framerate_str)
176
+
177
+ def _resolve_explicit_tickrate(self, tt_attrs):
178
+ """Parse an explicit ttp:tickRate attribute."""
179
+ try:
180
+ tickrate = float(tt_attrs["ttp:tickrate"])
181
+ except ValueError:
182
+ raise CaptionReadSyntaxError(
183
+ f"ttp:tickRate must be a number, "
184
+ f"got '{tt_attrs['ttp:tickrate']}'"
185
+ )
186
+ if tickrate <= 0:
187
+ raise CaptionReadSyntaxError(
188
+ f"ttp:tickRate must be positive, got '{tt_attrs['ttp:tickrate']}'"
189
+ )
190
+ self.tickrate = tickrate
191
+
192
+ def _resolve_default_tickrate(self, tt_attrs, framerate_str):
193
+ """Compute default tickRate = frameRate × subFrameRate (TTML 8.2.12)."""
194
+ try:
195
+ sub_framerate = int(
196
+ tt_attrs.get("ttp:subframerate", DFXP_DEFAULT_SUBFRAMERATE)
197
+ )
198
+ except ValueError:
199
+ raise CaptionReadSyntaxError(
200
+ f"ttp:subFrameRate must be a positive integer, "
201
+ f"got '{tt_attrs['ttp:subframerate']}'"
202
+ )
203
+ try:
204
+ framerate_int = int(framerate_str)
205
+ except ValueError:
206
+ raise CaptionReadSyntaxError(
207
+ f"ttp:frameRate must be a positive integer, "
208
+ f"got '{framerate_str}'"
209
+ )
210
+ self.tickrate = float(framerate_int * sub_framerate)
211
+
195
212
  def _convert_div_to_caption_list(self, div):
196
213
  """Convert a <div> element into a CaptionList for one language.
197
214