pycaption 2.3.1__tar.gz → 2.3.3__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.1/pycaption.egg-info → pycaption-2.3.3}/PKG-INFO +1 -1
  2. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/base.py +134 -72
  3. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/constants.py +12 -0
  4. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/extras.py +8 -5
  5. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/reader.py +61 -45
  6. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/writer.py +70 -20
  7. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/geometry.py +127 -201
  8. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/microdvd.py +39 -26
  9. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/reader.py +15 -11
  10. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/writer.py +96 -22
  11. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/reader.py +86 -127
  12. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/specialized_collections.py +143 -194
  13. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/state_machines.py +4 -5
  14. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/writer.py +91 -82
  15. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/srt.py +20 -31
  16. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/transcript.py +6 -4
  17. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/reader.py +36 -50
  18. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/writer.py +32 -40
  19. {pycaption-2.3.1 → pycaption-2.3.3/pycaption.egg-info}/PKG-INFO +1 -1
  20. {pycaption-2.3.1 → pycaption-2.3.3}/setup.py +1 -1
  21. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/webvtt.py +4 -4
  22. {pycaption-2.3.1 → pycaption-2.3.3}/tests/mixins.py +23 -7
  23. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_dfxp_conversion.py +7 -1
  24. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_sami.py +1 -1
  25. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_sami_conversion.py +8 -4
  26. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc.py +8 -4
  27. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc_conversion.py +22 -11
  28. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_webvtt_conversion.py +10 -5
  29. {pycaption-2.3.1 → pycaption-2.3.3}/LICENSE +0 -0
  30. {pycaption-2.3.1 → pycaption-2.3.3}/MANIFEST.in +0 -0
  31. {pycaption-2.3.1 → pycaption-2.3.3}/README.rst +0 -0
  32. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/__init__.py +0 -0
  33. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/dfxp/__init__.py +0 -0
  34. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/exceptions.py +0 -0
  35. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/__init__.py +0 -0
  36. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/constants.py +0 -0
  37. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/sami/parser.py +0 -0
  38. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/__init__.py +0 -0
  39. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/constants.py +0 -0
  40. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/scc/translator.py +0 -0
  41. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/utils.py +0 -0
  42. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/__init__.py +0 -0
  43. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption/webvtt/constants.py +0 -0
  44. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/SOURCES.txt +0 -0
  45. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/dependency_links.txt +0 -0
  46. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/requires.txt +0 -0
  47. {pycaption-2.3.1 → pycaption-2.3.3}/pycaption.egg-info/top_level.txt +0 -0
  48. {pycaption-2.3.1 → pycaption-2.3.3}/setup.cfg +0 -0
  49. {pycaption-2.3.1 → pycaption-2.3.3}/tests/__init__.py +0 -0
  50. {pycaption-2.3.1 → pycaption-2.3.3}/tests/conftest.py +0 -0
  51. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/__init__.py +0 -0
  52. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/dfxp.py +0 -0
  53. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/microdvd.py +0 -0
  54. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/sami.py +0 -0
  55. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/scc.py +0 -0
  56. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/srt.py +0 -0
  57. {pycaption-2.3.1 → pycaption-2.3.3}/tests/fixtures/translated_scc.py +0 -0
  58. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_base.py +0 -0
  59. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_dfxp.py +0 -0
  60. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_dfxp_extras.py +0 -0
  61. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_functions.py +0 -0
  62. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_geometry.py +0 -0
  63. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_microdvd.py +0 -0
  64. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_microdvd_conversion.py +0 -0
  65. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc_translator.py +0 -0
  66. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_scc_writer.py +0 -0
  67. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_srt.py +0 -0
  68. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_srt_conversion.py +0 -0
  69. {pycaption-2.3.1 → pycaption-2.3.3}/tests/test_webvtt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.1
3
+ Version: 2.3.3
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:
@@ -60,6 +64,33 @@ class BaseReader:
60
64
  def __init__(self, *args, **kwargs):
61
65
  pass
62
66
 
67
+ @staticmethod
68
+ def _decode_content(content):
69
+ """Decode bytes to str (UTF-8 with BOM handling).
70
+
71
+ :param content: str or bytes input.
72
+ :returns: decoded str with BOM stripped.
73
+ :raises InvalidInputError: if content is not str/bytes, is empty
74
+ bytes, or is not valid UTF-8.
75
+ """
76
+ if isinstance(content, bytes):
77
+ if not content:
78
+ raise InvalidInputError("The content is empty.")
79
+ try:
80
+ content = content.decode("utf-8-sig")
81
+ except UnicodeDecodeError as e:
82
+ raise InvalidInputError(
83
+ f"Content is not valid UTF-8: {e}"
84
+ ) from e
85
+ elif isinstance(content, str):
86
+ if content.startswith(""):
87
+ content = content[1:]
88
+ else:
89
+ raise InvalidInputError(
90
+ "The content must be a unicode string or UTF-8 bytes."
91
+ )
92
+ return content
93
+
63
94
  def detect(self, content):
64
95
  """Return True if content appears to be in this reader's format.
65
96
 
@@ -107,6 +138,28 @@ class BaseWriter:
107
138
  self.video_height = video_height
108
139
  self.fit_to_screen = fit_to_screen
109
140
 
141
+ @staticmethod
142
+ def _get_visual_alignment_default(caption_set):
143
+ """Return the source format's visual alignment default from a CaptionSet.
144
+
145
+ Per SMPTE RP 2052-10, caption formats have different implicit visual
146
+ defaults when no alignment is specified:
147
+ - CENTER: WebVTT, SRT, SCC, MicroDVD
148
+ - LEFT/START: DFXP/TTML, SAMI
149
+
150
+ When converting between formats with mismatched defaults, the source
151
+ default must be made explicit in the output to prevent visual
152
+ regression (e.g. centered text silently becoming left-aligned).
153
+
154
+ Readers declare their default via CaptionSet.visual_alignment_default.
155
+ Writers compare it to their own format's default to decide whether
156
+ to emit explicit alignment.
157
+
158
+ :type caption_set: CaptionSet
159
+ :rtype: HorizontalAlignmentEnum | None
160
+ """
161
+ return caption_set.visual_alignment_default if caption_set else None
162
+
110
163
  def _relativize_and_fit_to_screen(self, layout_info):
111
164
  """Apply relativization and fit-to-screen adjustments to a Layout.
112
165
 
@@ -124,13 +177,14 @@ class BaseWriter:
124
177
  layout_info = layout_info.fit_to_screen()
125
178
  return layout_info
126
179
 
127
- def write(self, content):
180
+ def write(self, caption_set, **kwargs):
128
181
  """Serialize a CaptionSet. Subclasses override this.
129
182
 
130
- :type content: CaptionSet
183
+ :type caption_set: CaptionSet
184
+ :param kwargs: Format-specific options (e.g. force, lang).
131
185
  :rtype: str
132
186
  """
133
- return content
187
+ return caption_set
134
188
 
135
189
 
136
190
  class CaptionNode:
@@ -146,8 +200,6 @@ class CaptionNode:
146
200
  """
147
201
 
148
202
  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
203
  STYLE = 2
152
204
  BREAK = 3
153
205
 
@@ -216,9 +268,9 @@ class Caption:
216
268
  """
217
269
  Initialize the Caption object
218
270
  :param start: The start time in microseconds
219
- :type start: Number
271
+ :type start: int
220
272
  :param end: The end time in microseconds
221
- :type end: Number
273
+ :type end: int
222
274
  :param nodes: A list of CaptionNodes
223
275
  :type nodes: list
224
276
  :param style: A dictionary with CSS-like styling rules
@@ -242,10 +294,8 @@ class Caption:
242
294
  self.nodes = nodes
243
295
  self.style = style or {}
244
296
  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
297
+ self.caption_mode = None
298
+ self.roll_up_rows = None
249
299
 
250
300
  def format_start(self, msec_separator=None):
251
301
  """Format start time as HH:MM:SS.mmm string.
@@ -273,33 +323,30 @@ class Caption:
273
323
 
274
324
  :rtype: list[str]
275
325
  """
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
326
+ return [
327
+ node.content if node.type_ == CaptionNode.TEXT else "\n"
328
+ for node in self.nodes
329
+ if node.type_ in (CaptionNode.TEXT, CaptionNode.BREAK)
330
+ ]
283
331
 
284
332
  def get_text(self):
285
333
  """Return the plain text content of this caption (no markup).
286
334
 
287
335
  :rtype: str
288
336
  """
289
- text_nodes = self.get_text_nodes()
290
- return "".join(text_nodes).strip()
337
+ return "".join(self.get_text_nodes()).strip()
291
338
 
292
- def _format_timestamp(self, microseconds, msec_separator=None):
339
+ @staticmethod
340
+ def _format_timestamp(microseconds, msec_separator=None):
293
341
  """Convert microseconds to HH:MM:SS{sep}mmm string."""
294
342
  duration = timedelta(microseconds=microseconds)
295
343
  hours, rem = divmod(duration.seconds, 3600)
296
344
  minutes, seconds = divmod(rem, 60)
297
- milliseconds = f"{duration.microseconds // 1000:03d}"
298
- timestamp = (
345
+ milliseconds = duration.microseconds // 1000
346
+ return (
299
347
  f"{hours:02d}:{minutes:02d}:{seconds:02d}"
300
- f"{msec_separator or '.'}{milliseconds:.3s}"
348
+ f"{msec_separator or '.'}{milliseconds:03d}"
301
349
  )
302
- return timestamp
303
350
 
304
351
 
305
352
  class CaptionList(list):
@@ -320,14 +367,14 @@ class CaptionList(list):
320
367
  return item
321
368
  return CaptionList(item, layout_info=self.layout_info)
322
369
 
323
- def __add__(self, other):
370
+ def __add__(self, value):
324
371
  add_is_safe = (
325
- not hasattr(other, "layout_info")
326
- or not other.layout_info
327
- or self.layout_info == other.layout_info
372
+ not hasattr(value, "layout_info")
373
+ or not value.layout_info
374
+ or self.layout_info == value.layout_info
328
375
  )
329
376
  if add_is_safe:
330
- return CaptionList(list.__add__(self, other), layout_info=self.layout_info)
377
+ return CaptionList(list.__add__(self, value), layout_info=self.layout_info)
331
378
  else:
332
379
  raise ValueError(
333
380
  "Cannot add CaptionList objects with different layout_info"
@@ -348,17 +395,27 @@ class CaptionSet:
348
395
  by all the children.
349
396
  """
350
397
 
351
- def __init__(self, captions, styles=None, layout_info=None, regions=None):
398
+ def __init__(
399
+ self, captions, styles=None, layout_info=None, regions=None,
400
+ visual_alignment_default=None,
401
+ ):
352
402
  """
353
403
  :param captions: A dictionary of the format {'language': CaptionList}
354
404
  :param styles: A dictionary with CSS-like styling rules
355
405
  :param Layout layout_info: A Layout object with the positioning info
356
406
  :param regions: A dictionary mapping region id to raw settings dict
407
+ :param visual_alignment_default: The source format's implicit text
408
+ alignment when no explicit alignment is specified. Per SMPTE
409
+ RP 2052-10, writers targeting a format with a different visual
410
+ default must emit this alignment explicitly to prevent visual
411
+ regression. Use HorizontalAlignmentEnum values.
412
+ :type visual_alignment_default: HorizontalAlignmentEnum | None
357
413
  """
358
414
  self._captions = captions
359
415
  self._styles = styles or {}
360
416
  self._regions = regions or {}
361
417
  self.layout_info = layout_info
418
+ self.visual_alignment_default = visual_alignment_default
362
419
 
363
420
  def set_captions(self, lang, captions):
364
421
  """Replace the caption list for a given language.
@@ -429,7 +486,10 @@ class CaptionSet:
429
486
 
430
487
  def is_empty(self):
431
488
  """Return True if no language contains any captions."""
432
- return all([len(captions) == 0 for captions in list(self._captions.values())])
489
+ for captions in self._captions.values():
490
+ if len(captions) > 0:
491
+ return False
492
+ return True
433
493
 
434
494
  def set_layout_info(self, lang, layout_info):
435
495
  """Set the layout_info on the CaptionList for a given language.
@@ -469,44 +529,46 @@ class CaptionSet:
469
529
  self.set_captions(lang, out_captions)
470
530
 
471
531
 
472
- # Functions
473
532
  def merge_concurrent_captions(caption_set):
474
533
  """Merge captions that have the same start and end times"""
475
534
  for lang in caption_set.get_languages():
476
535
  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)
536
+ merged = merge_caption_list(captions)
537
+ if merged:
538
+ caption_set.set_captions(lang, merged)
497
539
  return caption_set
498
540
 
499
541
 
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
- """
542
+ def merge_caption_list(captions):
543
+ """Merge consecutive captions with identical start/end times into one."""
544
+ if not captions:
545
+ return CaptionList()
546
+ last_caption = None
547
+ concurrent_captions = CaptionList()
548
+ merged_captions = CaptionList()
549
+ for caption in captions:
550
+ if last_caption:
551
+ last_timespan = last_caption.start, last_caption.end
552
+ current_timespan = caption.start, caption.end
553
+ if current_timespan == last_timespan:
554
+ concurrent_captions.append(caption)
555
+ last_caption = caption
556
+ continue
557
+ else:
558
+ merged_captions.append(_merge_group(concurrent_captions))
559
+ concurrent_captions = [caption]
560
+ last_caption = caption
561
+
562
+ if concurrent_captions:
563
+ merged_captions.append(_merge_group(concurrent_captions))
564
+ return merged_captions
565
+
566
+
567
+ def _merge_group(captions):
568
+ """Merge a group of captions into one, keeping the first caption's timing."""
505
569
  new_nodes = []
506
570
  for caption in captions:
507
571
  if new_nodes:
508
572
  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
573
+ new_nodes.extend(caption.nodes)
574
+ 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
 
@@ -21,10 +21,10 @@ from ..exceptions import (
21
21
  CaptionReadNoCaptions,
22
22
  CaptionReadSyntaxError,
23
23
  CaptionReadTimingError,
24
- InvalidInputError,
25
24
  )
26
25
  from ..geometry import (
27
26
  Alignment,
27
+ HorizontalAlignmentEnum,
28
28
  Layout,
29
29
  Padding,
30
30
  Point,
@@ -107,9 +107,10 @@ class DFXPReader(BaseReader):
107
107
  def detect(self, content):
108
108
  """Return True if content looks like a DFXP/TTML document.
109
109
 
110
- :type content: str
110
+ :type content: str or bytes
111
111
  :rtype: bool
112
112
  """
113
+ content = self._decode_content(content)
113
114
  lowered = content.lower()
114
115
  return bool(re.search(r"<tt[\s>]", lowered)) and "</tt>" in lowered
115
116
 
@@ -121,52 +122,14 @@ class DFXPReader(BaseReader):
121
122
  :raises InvalidInputError: if content is not a string
122
123
  :raises CaptionReadNoCaptions: if no captions are found
123
124
  """
124
- if not isinstance(content, str):
125
- raise InvalidInputError("The content is not a unicode string.")
125
+ content = self._decode_content(content)
126
126
 
127
127
  dfxp_document = LayoutAwareDFXPParser(
128
128
  content, read_invalid_positioning=self.read_invalid_positioning
129
129
  )
130
130
 
131
131
  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)
132
+ self._resolve_timing_parameters(tt_attrs)
170
133
 
171
134
  caption_dict = {}
172
135
  style_dict = {}
@@ -180,18 +143,71 @@ class DFXPReader(BaseReader):
180
143
  for style in dfxp_document.find_all("style"):
181
144
  id_ = style.attrs.get(DFXP_ATTR_XML_ID) or style.attrs.get("id")
182
145
  if id_:
183
- # Styles nested inside <region> tags are region-scoped and
184
- # should not appear as document-level styles.
185
146
  if "region" not in [parent_.name for parent_ in style.parents]:
186
147
  style_dict[id_] = self._convert_style(style)
187
148
 
188
- caption_set = CaptionSet(caption_dict, styles=style_dict)
149
+ caption_set = CaptionSet(
150
+ caption_dict, styles=style_dict,
151
+ visual_alignment_default=HorizontalAlignmentEnum.START,
152
+ )
189
153
 
190
154
  if caption_set.is_empty():
191
155
  raise CaptionReadNoCaptions("empty caption file")
192
156
 
193
157
  return caption_set
194
158
 
159
+ def _resolve_timing_parameters(self, tt_attrs):
160
+ """Extract framerate and tickrate from <tt> attributes.
161
+
162
+ Sets self.framerate and self.tickrate per TTML spec sections 8.2.8,
163
+ 8.2.11, and 8.2.12.
164
+ """
165
+ framerate_str = tt_attrs.get("ttp:framerate", str(DFXP_DEFAULT_FRAMERATE))
166
+ multiplier_str = tt_attrs.get(
167
+ "ttp:frameratemultiplier", DFXP_DEFAULT_FRAMERATE_MULTIPLIER
168
+ )
169
+ self.framerate = self._get_effective_framerate(framerate_str, multiplier_str)
170
+
171
+ if "ttp:tickrate" in tt_attrs:
172
+ self._resolve_explicit_tickrate(tt_attrs)
173
+ else:
174
+ self._resolve_default_tickrate(tt_attrs, framerate_str)
175
+
176
+ def _resolve_explicit_tickrate(self, tt_attrs):
177
+ """Parse an explicit ttp:tickRate attribute."""
178
+ try:
179
+ tickrate = float(tt_attrs["ttp:tickrate"])
180
+ except ValueError:
181
+ raise CaptionReadSyntaxError(
182
+ f"ttp:tickRate must be a number, "
183
+ f"got '{tt_attrs['ttp:tickrate']}'"
184
+ )
185
+ if tickrate <= 0:
186
+ raise CaptionReadSyntaxError(
187
+ f"ttp:tickRate must be positive, got '{tt_attrs['ttp:tickrate']}'"
188
+ )
189
+ self.tickrate = tickrate
190
+
191
+ def _resolve_default_tickrate(self, tt_attrs, framerate_str):
192
+ """Compute default tickRate = frameRate × subFrameRate (TTML 8.2.12)."""
193
+ try:
194
+ sub_framerate = int(
195
+ tt_attrs.get("ttp:subframerate", DFXP_DEFAULT_SUBFRAMERATE)
196
+ )
197
+ except ValueError:
198
+ raise CaptionReadSyntaxError(
199
+ f"ttp:subFrameRate must be a positive integer, "
200
+ f"got '{tt_attrs['ttp:subframerate']}'"
201
+ )
202
+ try:
203
+ framerate_int = int(framerate_str)
204
+ except ValueError:
205
+ raise CaptionReadSyntaxError(
206
+ f"ttp:frameRate must be a positive integer, "
207
+ f"got '{framerate_str}'"
208
+ )
209
+ self.tickrate = float(framerate_int * sub_framerate)
210
+
195
211
  def _convert_div_to_caption_list(self, div):
196
212
  """Convert a <div> element into a CaptionList for one language.
197
213