pycaption 2.3.9__tar.gz → 2.3.12__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 (74) hide show
  1. {pycaption-2.3.9/pycaption.egg-info → pycaption-2.3.12}/PKG-INFO +1 -1
  2. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/__init__.py +1 -0
  3. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/base.py +3 -2
  4. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/constants.py +1 -0
  5. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/extras.py +2 -1
  6. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/reader.py +6 -6
  7. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/writer.py +285 -18
  8. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/geometry.py +3 -0
  9. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/microdvd.py +1 -0
  10. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/constants.py +1 -0
  11. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/reader.py +1 -0
  12. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/writer.py +1 -0
  13. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/__init__.py +1 -0
  14. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/constants.py +5 -0
  15. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/reader.py +21 -2
  16. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/specialized_collections.py +429 -58
  17. pycaption-2.3.12/pycaption/scc/state_machines.py +413 -0
  18. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/writer.py +37 -13
  19. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/srt.py +20 -4
  20. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/transcript.py +7 -1
  21. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/__init__.py +1 -0
  22. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/constants.py +1 -0
  23. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/reader.py +1 -1
  24. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/writer.py +264 -45
  25. {pycaption-2.3.9 → pycaption-2.3.12/pycaption.egg-info}/PKG-INFO +1 -1
  26. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/SOURCES.txt +0 -1
  27. pycaption-2.3.12/setup.cfg +4 -0
  28. {pycaption-2.3.9 → pycaption-2.3.12}/setup.py +3 -2
  29. {pycaption-2.3.9 → pycaption-2.3.12}/tests/conftest.py +41 -6
  30. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/scc.py +561 -3
  31. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/webvtt.py +1 -1
  32. {pycaption-2.3.9 → pycaption-2.3.12}/tests/mixins.py +1 -0
  33. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_base.py +72 -0
  34. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_bytes_input.py +0 -2
  35. pycaption-2.3.12/tests/test_dfxp.py +868 -0
  36. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_dfxp_conversion.py +4 -3
  37. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_double_encoding.py +1 -3
  38. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_sami.py +2 -8
  39. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_sami_conversion.py +4 -7
  40. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc.py +718 -40
  41. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc_conversion.py +89 -5
  42. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc_translator.py +1 -3
  43. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc_writer.py +107 -49
  44. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_srt.py +66 -1
  45. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_webvtt.py +12 -32
  46. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_webvtt_conversion.py +590 -34
  47. pycaption-2.3.9/pycaption/scc/state_machines.py +0 -207
  48. pycaption-2.3.9/setup.cfg +0 -14
  49. pycaption-2.3.9/tests/test_dfxp.py +0 -441
  50. {pycaption-2.3.9 → pycaption-2.3.12}/LICENSE +0 -0
  51. {pycaption-2.3.9 → pycaption-2.3.12}/MANIFEST.in +0 -0
  52. {pycaption-2.3.9 → pycaption-2.3.12}/README.rst +0 -0
  53. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/__init__.py +0 -0
  54. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/exceptions.py +0 -0
  55. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/__init__.py +0 -0
  56. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/parser.py +0 -0
  57. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/translator.py +0 -0
  58. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/utils.py +0 -0
  59. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/dependency_links.txt +0 -0
  60. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/requires.txt +0 -0
  61. {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/top_level.txt +0 -0
  62. {pycaption-2.3.9 → pycaption-2.3.12}/tests/__init__.py +0 -0
  63. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/__init__.py +0 -0
  64. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/dfxp.py +0 -0
  65. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/microdvd.py +0 -0
  66. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/sami.py +0 -0
  67. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/srt.py +0 -0
  68. {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/translated_scc.py +0 -0
  69. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_dfxp_extras.py +0 -0
  70. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_functions.py +0 -0
  71. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_geometry.py +0 -0
  72. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_microdvd.py +0 -0
  73. {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_microdvd_conversion.py +0 -0
  74. {pycaption-2.3.9 → pycaption-2.3.12}/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.9
3
+ Version: 2.3.12
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -21,6 +21,7 @@ from .srt import SRTReader, SRTWriter
21
21
  from .transcript import TranscriptWriter
22
22
  from .webvtt import WebVTTReader, WebVTTWriter
23
23
 
24
+
24
25
  __all__ = [
25
26
  "CaptionConverter",
26
27
  "DFXPReader",
@@ -7,11 +7,13 @@ CaptionConverter orchestrator and base classes for readers/writers.
7
7
 
8
8
  import logging
9
9
  import os
10
+
10
11
  from datetime import timedelta
11
12
  from numbers import Number
12
13
 
13
14
  from .exceptions import CaptionReadError, CaptionReadTimingError, InvalidInputError
14
15
 
16
+
15
17
  logger = logging.getLogger(__name__)
16
18
 
17
19
  # `und` a special identifier for an undetermined language according to ISO 639-2
@@ -40,8 +42,7 @@ class CaptionConverter:
40
42
  """
41
43
  if not hasattr(caption_reader, "read"):
42
44
  raise InvalidInputError(
43
- "The caption_reader must be a BaseReader instance "
44
- "with a read() method."
45
+ "The caption_reader must be a BaseReader instance with a read() method."
45
46
  )
46
47
  self.captions = caption_reader.read(content)
47
48
  return self
@@ -8,6 +8,7 @@ import re
8
8
 
9
9
  from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, VerticalAlignmentEnum
10
10
 
11
+
11
12
  DFXP_BASE_MARKUP = """
12
13
  <tt xmlns="http://www.w3.org/ns/ttml"
13
14
  xmlns:tts="http://www.w3.org/ns/ttml#styling">
@@ -13,6 +13,7 @@ from ..base import BaseWriter, CaptionNode, merge_concurrent_captions
13
13
  from .constants import DFXP_DEFAULT_REGION
14
14
  from .writer import DFXPWriter
15
15
 
16
+
16
17
  LEGACY_DFXP_BASE_MARKUP = """
17
18
  <tt xmlns="http://www.w3.org/ns/ttml"
18
19
  xmlns:tts="http://www.w3.org/ns/ttml#styling">
@@ -255,7 +256,7 @@ class LegacyDFXPWriter(BaseWriter):
255
256
 
256
257
  return line
257
258
 
258
- def _recreate_style(self, content, dfxp):
259
+ def _recreate_style(self, content, dfxp): # noqa: C901
259
260
  """Convert an internal style dict to DFXP/TTS attributes.
260
261
 
261
262
  :type content: dict
@@ -47,6 +47,7 @@ from .constants import (
47
47
  VERTICAL_ALIGNMENT_TO_DFXP,
48
48
  )
49
49
 
50
+
50
51
  _LEADING_WHITESPACE_RE = re.compile("^(?:[\n\r]+\\s*)?(.+)")
51
52
 
52
53
  _DFXP_WRITING_MODE_MAP = {
@@ -147,7 +148,8 @@ class DFXPReader(BaseReader):
147
148
  style_dict[id_] = self._convert_style(style)
148
149
 
149
150
  caption_set = CaptionSet(
150
- caption_dict, styles=style_dict,
151
+ caption_dict,
152
+ styles=style_dict,
151
153
  visual_alignment_default=HorizontalAlignmentEnum.START,
152
154
  )
153
155
 
@@ -179,8 +181,7 @@ class DFXPReader(BaseReader):
179
181
  tickrate = float(tt_attrs["ttp:tickrate"])
180
182
  except ValueError:
181
183
  raise CaptionReadSyntaxError(
182
- f"ttp:tickRate must be a number, "
183
- f"got '{tt_attrs['ttp:tickrate']}'"
184
+ f"ttp:tickRate must be a number, got '{tt_attrs['ttp:tickrate']}'"
184
185
  )
185
186
  if tickrate <= 0:
186
187
  raise CaptionReadSyntaxError(
@@ -203,8 +204,7 @@ class DFXPReader(BaseReader):
203
204
  framerate_int = int(framerate_str)
204
205
  except ValueError:
205
206
  raise CaptionReadSyntaxError(
206
- f"ttp:frameRate must be a positive integer, "
207
- f"got '{framerate_str}'"
207
+ f"ttp:frameRate must be a positive integer, got '{framerate_str}'"
208
208
  )
209
209
  self.tickrate = float(framerate_int * sub_framerate)
210
210
 
@@ -396,7 +396,7 @@ class DFXPReader(BaseReader):
396
396
  self._convert_tag_to_node(a)
397
397
 
398
398
  @staticmethod
399
- def _convert_style(tag):
399
+ def _convert_style(tag): # noqa: C901
400
400
  """Convert DFXP/TTS style attributes on a tag to an internal style dict.
401
401
 
402
402
  Maps tts:fontStyle, tts:fontWeight, tts:textDecoration, tts:textAlign,
@@ -5,6 +5,7 @@ including style elements, region-based positioning, and writing direction.
5
5
  """
6
6
 
7
7
  import re
8
+
8
9
  from copy import deepcopy
9
10
  from xml.sax.saxutils import escape
10
11
 
@@ -26,6 +27,7 @@ from .constants import (
26
27
  _create_external_alignment,
27
28
  )
28
29
 
30
+
29
31
  _WRITING_DIRECTION_TO_DFXP = {
30
32
  WritingDirectionEnum.VERTICAL_RL: "tbrl",
31
33
  WritingDirectionEnum.VERTICAL_LR: "tblr",
@@ -213,39 +215,278 @@ class DFXPWriter(BaseWriter):
213
215
 
214
216
  return p
215
217
 
216
- def _recreate_text(self, caption, dfxp, caption_set=None, lang=None):
218
+ def _recreate_text(self, caption, dfxp, caption_set=None, lang=None): # noqa: C901
217
219
  """Serialize all nodes of a caption into DFXP inline markup.
218
220
 
219
- Handles text nodes, line breaks (<br/>), and style spans.
221
+ Handles text nodes, line breaks (<br/>), and style spans. Nodes are
222
+ first split into runs sharing one region; a run whose region is not
223
+ the one already in effect is wrapped in a <span> carrying it, so text
224
+ renders at its own position instead of inheriting the one on the
225
+ enclosing <p>, and a <br/> between two nodes of one region stays
226
+ inside that region. A run holding no visible text takes no wrapper:
227
+ there is nothing for the region to place.
220
228
 
221
229
  :rtype: str
222
230
  """
223
231
  line = ""
224
232
  self._span_stack = []
233
+ paragraph = self.region_creator.get_positioning_info(lang, caption_set, caption)
234
+ paragraph_region, _ = paragraph
235
+
236
+ for region_id, region_attribs, nodes, wrappable in self._group_nodes_by_region(
237
+ caption, paragraph, caption_set, lang
238
+ ):
239
+ wrap = (
240
+ wrappable
241
+ and region_id != self._innermost_region(paragraph_region)
242
+ and self._holds_visible_text(nodes)
243
+ )
244
+ if wrap:
245
+ attrs = {"region": region_id}
246
+ if self.write_inline_positioning:
247
+ attrs.update(region_attribs)
248
+ attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
249
+ line += f"<span {attr_str}>"
225
250
 
226
- for node in caption.nodes:
227
- if node.type_ == CaptionNode.TEXT:
228
- line += escape(node.content)
251
+ for node in nodes:
252
+ if node.type_ == CaptionNode.TEXT:
253
+ line += escape(node.content)
229
254
 
230
- elif node.type_ == CaptionNode.BREAK:
231
- line = line.rstrip() + "<br/>\n "
255
+ elif node.type_ == CaptionNode.BREAK:
256
+ line = line.rstrip() + "<br/>\n "
257
+
258
+ elif node.type_ == CaptionNode.STYLE:
259
+ line = self._recreate_span(
260
+ line, node, dfxp, caption_set, caption, lang
261
+ )
232
262
 
233
- elif node.type_ == CaptionNode.STYLE:
234
- line = self._recreate_span(line, node, dfxp, caption_set, caption, lang)
263
+ if wrap:
264
+ line = line.rstrip() + "</span> "
235
265
 
236
266
  while self._span_stack:
237
- line = line.rstrip() + "</span> "
238
- self._span_stack.pop()
267
+ emitted, _ = self._span_stack.pop()
268
+ if emitted:
269
+ line = line.rstrip() + "</span> "
239
270
 
240
271
  return line.rstrip()
241
272
 
273
+ def _group_nodes_by_region(self, caption, paragraph, caption_set, lang):
274
+ """Split a caption's nodes into consecutive runs sharing one region.
275
+
276
+ Line breaks are neutral: a run absorbs one only when it continues
277
+ past it, so a break between two nodes of the same region ends up
278
+ inside that region's span rather than outside it. Breaks left over
279
+ between two runs form a run of their own, which is never wrapped.
280
+ A run is split again around any style tag whose partner falls
281
+ outside it, so one such tag costs only itself a wrapper.
282
+
283
+ :param paragraph: (region_id, positioning attributes) of the enclosing <p>
284
+ :return: (region_id, positioning_attributes, nodes, wrappable) per run
285
+ :rtype: list[tuple[str | None, dict, list[CaptionNode], bool]]
286
+ """
287
+ resolved, partners = self._resolve_node_regions(
288
+ caption, paragraph, caption_set, lang
289
+ )
290
+
291
+ groups = []
292
+ current = None
293
+ pending = []
294
+
295
+ for index, (region_id, region_attribs) in enumerate(resolved):
296
+ if region_id is None:
297
+ pending.append(index)
298
+ continue
299
+
300
+ if current and region_id == current[0]:
301
+ current[2].extend(pending)
302
+ else:
303
+ if current:
304
+ groups.append(current)
305
+ if pending:
306
+ groups.append((None, {}, pending))
307
+ current = [region_id, region_attribs, []]
308
+ pending = []
309
+ current[2].append(index)
310
+
311
+ if current:
312
+ groups.append(current)
313
+ if pending:
314
+ groups.append((None, {}, pending))
315
+
316
+ return [
317
+ (
318
+ region_id,
319
+ region_attribs,
320
+ [caption.nodes[index] for index in segment],
321
+ region_id is not None
322
+ and self._is_wrappable(segment, partners, caption.nodes),
323
+ )
324
+ for region_id, region_attribs, indices in groups
325
+ for segment in self._split_at_crossing_styles(
326
+ indices, partners, caption.nodes
327
+ )
328
+ ]
329
+
330
+ @staticmethod
331
+ def _holds_visible_text(nodes):
332
+ """Check whether a run has any text for a region to place.
333
+
334
+ A run of nothing but style tags and breaks renders nothing, so a
335
+ wrapper carrying its region would be an empty span.
336
+
337
+ :rtype: bool
338
+ """
339
+ return any(
340
+ node.type_ == CaptionNode.TEXT and node.content.strip() for node in nodes
341
+ )
342
+
343
+ @staticmethod
344
+ def _crosses_run(index, partners, nodes, run):
345
+ """Check whether the node at an index leaves the run it belongs to.
346
+
347
+ A style tag whose partner sits outside the run does: a span wrapping
348
+ it would cross that tag's own span and produce invalid XML. A
349
+ closing tag with no partner at all is the exception — nothing was
350
+ opened for it, so it writes no tag and crosses nothing.
351
+
352
+ :param run: the indices making up the run
353
+ :type run: set[int]
354
+ :rtype: bool
355
+ """
356
+ node = nodes[index]
357
+ if node.type_ != CaptionNode.STYLE:
358
+ return False
359
+
360
+ if not node.start and index not in partners:
361
+ return False
362
+
363
+ return partners.get(index) not in run
364
+
365
+ @classmethod
366
+ def _split_at_crossing_styles(cls, indices, partners, nodes):
367
+ """Split a run around style tags whose partner sits outside it.
368
+
369
+ A span wrapping such a tag would cross another span and produce
370
+ invalid XML, but only that tag is in the way — the nodes beside it
371
+ still deserve their own region, so they come back as runs of their
372
+ own instead of losing their position along with it.
373
+
374
+ :rtype: list[list[int]]
375
+ """
376
+ run = set(indices)
377
+ segments = [[]]
378
+
379
+ for index in indices:
380
+ if cls._crosses_run(index, partners, nodes, run):
381
+ segments += [[index], []]
382
+ else:
383
+ segments[-1].append(index)
384
+
385
+ return [segment for segment in segments if segment]
386
+
387
+ def _resolve_node_regions(self, caption, paragraph, caption_set, lang):
388
+ """Resolve the region every node of a caption belongs to.
389
+
390
+ A node's own layout wins; lacking one it takes the region in effect
391
+ around it — the innermost enclosing style span's, else the
392
+ paragraph's. For a closing style node that is its own opener's
393
+ region, so a span is never separated from its own end tag. Line
394
+ breaks belong to no region and resolve to None, as does a closing
395
+ tag with nothing open — it writes no tag at all. A region travels
396
+ with the attributes that describe it, so a span reinstating one can
397
+ also write it inline.
398
+
399
+ :param paragraph: (region_id, positioning attributes) of the <p>
400
+ :return: a (region_id, positioning_attributes) pair per node, plus a
401
+ map pairing the index of each style tag with its partner's
402
+ :rtype: tuple[list[tuple[str | None, dict]], dict]
403
+ """
404
+ creator = self.region_creator
405
+ resolved = []
406
+ partners = {}
407
+ open_styles = []
408
+
409
+ for index, node in enumerate(caption.nodes):
410
+ in_effect = open_styles[-1][1] if open_styles else paragraph
411
+
412
+ if node.type_ == CaptionNode.BREAK:
413
+ resolved.append((None, {}))
414
+
415
+ elif node.type_ == CaptionNode.TEXT:
416
+ own = (None, {})
417
+ if node.content.strip():
418
+ own = creator.get_node_positioning_info(node.layout_info)
419
+ resolved.append(own if own[0] else in_effect)
420
+
421
+ elif node.start:
422
+ if node.layout_info:
423
+ region = creator.get_positioning_info(
424
+ lang, caption_set, caption, node
425
+ )
426
+ else:
427
+ region = in_effect
428
+ open_styles.append((index, region))
429
+ resolved.append(region)
430
+
431
+ elif open_styles:
432
+ opener, _ = open_styles.pop()
433
+ partners[opener] = index
434
+ partners[index] = opener
435
+ resolved.append(in_effect)
436
+
437
+ else:
438
+ resolved.append((None, {}))
439
+
440
+ return resolved, partners
441
+
442
+ @classmethod
443
+ def _is_wrappable(cls, indices, partners, nodes):
444
+ """Check whether a run of nodes can take a <span> holding its region.
445
+
446
+ No style tag in the run may leave it, or the wrapping span would
447
+ cross a style span and produce invalid XML — which is what rules out
448
+ a tag split off on its own. A run that is nothing but one style span
449
+ already writes any region of its own on that span, so it needs no
450
+ wrapper.
451
+
452
+ :rtype: bool
453
+ """
454
+ run = set(indices)
455
+ for index in indices:
456
+ if cls._crosses_run(index, partners, nodes, run):
457
+ return False
458
+
459
+ first = nodes[indices[0]]
460
+
461
+ return not (
462
+ first.type_ == CaptionNode.STYLE
463
+ and first.start
464
+ and partners.get(indices[0]) == indices[-1]
465
+ )
466
+
467
+ def _innermost_region(self, default):
468
+ """Return the region in effect at the current point in the caption.
469
+
470
+ The innermost open span carrying a region wins; with no such span,
471
+ the region on the enclosing <p> applies.
472
+
473
+ :param default: region of the enclosing <p>
474
+ :rtype: str
475
+ """
476
+ for _, region_id in reversed(self._span_stack):
477
+ if region_id:
478
+ return region_id
479
+
480
+ return default
481
+
242
482
  def _recreate_span(
243
483
  self, line, node, dfxp, caption_set=None, caption=None, lang=None
244
484
  ):
245
485
  """Open or close a <span> element for a style node.
246
486
 
247
487
  Supports nested spans via a stack. Opening pushes onto the stack;
248
- closing pops the most recent span.
488
+ closing pops the most recent span. Each entry records whether a tag
489
+ was actually emitted and which region it carries, if any.
249
490
 
250
491
  :param line: the accumulated markup string so far
251
492
  :type line: str
@@ -254,6 +495,7 @@ class DFXPWriter(BaseWriter):
254
495
  """
255
496
  if node.start:
256
497
  attrs = _recreate_style(node.content, dfxp)
498
+ region_id = None
257
499
  if node.layout_info:
258
500
  region_id, region_attribs = self.region_creator.get_positioning_info(
259
501
  lang, caption_set, caption, node
@@ -265,13 +507,13 @@ class DFXPWriter(BaseWriter):
265
507
  if attrs:
266
508
  attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
267
509
  line += f"<span {attr_str}>"
268
- self._span_stack.append(True)
510
+ self._span_stack.append((True, region_id))
269
511
  else:
270
- self._span_stack.append(False)
512
+ self._span_stack.append((False, None))
271
513
 
272
514
  else:
273
515
  if self._span_stack:
274
- had_span = self._span_stack.pop()
516
+ had_span, _ = self._span_stack.pop()
275
517
  if had_span:
276
518
  line = line.rstrip() + "</span> "
277
519
 
@@ -331,7 +573,12 @@ class RegionCreator:
331
573
  """Create <region> tags in the <layout> section for each Layout.
332
574
 
333
575
  Skips Layout objects that have no positioning data (no origin,
334
- extent, padding, alignment, or writing_direction).
576
+ extent, padding, alignment, or writing_direction). A line alignment
577
+ on its own is not positioning data: it is what a WebVTT ``line``
578
+ setting leaves behind when its value yields no line to place the cue
579
+ at — ``line:auto,start``, or a value the grammar discards outright —
580
+ so the cue keeps the default placement rather than taking a vertical
581
+ one from the qualifier alone.
335
582
 
336
583
  :param unique_layouts: iterable of geometry.Layout instances
337
584
  :type dfxp: BeautifulSoup
@@ -450,6 +697,26 @@ class RegionCreator:
450
697
 
451
698
  return region_id, positioning_attributes
452
699
 
700
+ def get_node_positioning_info(self, layout_info):
701
+ """Return (region_id, positioning_attributes) for a node's own layout.
702
+
703
+ Unlike get_positioning_info, this neither cascades to the caption and
704
+ the caption set nor falls back to the default region: a layout without
705
+ a region of its own yields (None, {}).
706
+
707
+ :type layout_info: geometry.Layout | None
708
+ :rtype: tuple[str | None, dict]
709
+ """
710
+ region_id = self._region_map.get(layout_info)
711
+ if not region_id:
712
+ return None, {}
713
+
714
+ self._assigned_region_ids.add(region_id)
715
+
716
+ return region_id, _convert_layout_to_attributes(
717
+ layout_info, self._fallback_alignment
718
+ )
719
+
453
720
  def cleanup_regions(self):
454
721
  """Remove <region> tags that were never assigned to any element."""
455
722
  layout_tag = self._dfxp.find("layout")
@@ -491,7 +758,7 @@ def _text_shadow_to_outline(value):
491
758
  return thickness
492
759
 
493
760
 
494
- def _recreate_style(content, dfxp):
761
+ def _recreate_style(content, dfxp): # noqa: C901
495
762
  """Convert an internal style dict to DFXP/TTS style attributes.
496
763
 
497
764
  Maps pycaption's internal keys (class, italics, bold, underline, color,
@@ -542,7 +809,7 @@ def _recreate_style(content, dfxp):
542
809
  return dfxp_style
543
810
 
544
811
 
545
- def _convert_layout_to_attributes(layout, fallback_alignment=None):
812
+ def _convert_layout_to_attributes(layout, fallback_alignment=None): # noqa: C901
546
813
  """Convert a Layout object to a dict of DFXP region attributes.
547
814
 
548
815
  Maps origin, extent, padding, alignment, and writing_direction to their
@@ -7,12 +7,15 @@ CONVENTIONS:
7
7
  responsible for the recalculation should return a new object with the
8
8
  necessary modifications.
9
9
  """
10
+
10
11
  import re
12
+
11
13
  from enum import Enum
12
14
  from functools import total_ordering
13
15
 
14
16
  from .exceptions import CaptionReadSyntaxError, RelativizationError
15
17
 
18
+
16
19
  _UNIT_MISMATCH_MSG = "The sizes should have the same measure units."
17
20
 
18
21
 
@@ -5,6 +5,7 @@ A special line {0}{0}fps_value sets the framerate (default 25 fps).
5
5
  """
6
6
 
7
7
  import re
8
+
8
9
  from copy import deepcopy
9
10
 
10
11
  from .base import (
@@ -10,6 +10,7 @@ from cssutils import log
10
10
 
11
11
  from ..geometry import HorizontalAlignmentEnum
12
12
 
13
+
13
14
  log.setLevel(FATAL)
14
15
 
15
16
  SAMI_BASE_MARKUP = """
@@ -11,6 +11,7 @@ from ..exceptions import CaptionReadNoCaptions, CaptionReadTimingError
11
11
  from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, Padding, Size
12
12
  from .parser import SAMIParser
13
13
 
14
+
14
15
  _TAG_TO_STYLE = {"i": "italics", "b": "bold", "u": "underline"}
15
16
 
16
17
 
@@ -13,6 +13,7 @@ from ..base import BaseWriter, CaptionNode
13
13
  from ..geometry import HorizontalAlignmentEnum
14
14
  from .constants import HORIZONTAL_ALIGNMENT_MAP, SAMI_BASE_MARKUP
15
15
 
16
+
16
17
  _NON_CSS_KEYS = frozenset(
17
18
  {
18
19
  "classes",
@@ -3,4 +3,5 @@
3
3
  from .reader import SCCReader
4
4
  from .writer import SCC_TOKENS_PER_CAPTION_MAX, SCCWriter
5
5
 
6
+
6
7
  __all__ = ["SCCReader", "SCCWriter", "SCC_TOKENS_PER_CAPTION_MAX"]
@@ -7,8 +7,10 @@ background color codes, and derived inverse lookup tables.
7
7
  """
8
8
 
9
9
  import re as _re
10
+
10
11
  from itertools import product
11
12
 
13
+
12
14
  COMMANDS = {
13
15
  "9420": "",
14
16
  "9429": "",
@@ -1317,6 +1319,9 @@ SPECIAL_OR_EXTENDED_CHAR_TO_CODE = {
1317
1319
  SPECIAL_OR_EXTENDED_CHAR_TO_CODE.update(
1318
1320
  {character: code for code, character in SPECIAL_CHARS.items()}
1319
1321
  )
1322
+ # A non-breaking space is what CEA-608's transparent space is: a blank column
1323
+ # that is not a break opportunity. Unmapped, it fell back to the pound sign.
1324
+ SPECIAL_OR_EXTENDED_CHAR_TO_CODE["\xa0"] = "91b9"
1320
1325
 
1321
1326
  # Time to transmit a single codeword = 1 second / 29.97
1322
1327
  MICROSECONDS_PER_CODEWORD = 1000.0 * 1000.0 / (30.0 * 1000.0 / 1001.0)
@@ -78,6 +78,7 @@ http://www.theneitherworld.com/mcpoodle/SCC_TOOLS/DOCS/SCC_FORMAT.HTML
78
78
  """
79
79
 
80
80
  import re
81
+
81
82
  from collections import deque
82
83
  from copy import deepcopy
83
84
 
@@ -108,6 +109,7 @@ from .specialized_collections import (
108
109
  )
109
110
  from .state_machines import DefaultProvidingPositionTracker
110
111
 
112
+
111
113
  _TIMECODE_RE = re.compile(r"\d{2}:\d{2}:\d{2}[:;](\d{1,2})")
112
114
 
113
115
 
@@ -242,7 +244,7 @@ class SCCReader(BaseReader):
242
244
  def _validate_line_lengths(self):
243
245
  """Raise CaptionLineLengthError if any line exceeds 32 characters."""
244
246
  violations = [
245
- f"around {cap.format_start()} - {line} - Length {len(line)}"
247
+ f"around {cap.format_start()} - {self._describe_line_length(line)}"
246
248
  for cap in (c.to_real_caption() for c in self.caption_stash._collection)
247
249
  for line in cap.get_text().split("\n")
248
250
  if len(line) > 32
@@ -253,6 +255,19 @@ class SCCReader(BaseReader):
253
255
  "Lines longer than 32:\n" + "\n".join(violations)
254
256
  )
255
257
 
258
+ @staticmethod
259
+ def _describe_line_length(line):
260
+ """Describe a line too long for the row, separating the text from the
261
+ indentation that places it, which can be what pushes it off the row.
262
+ """
263
+ text = line.lstrip("\xa0")
264
+ indent = len(line) - len(text)
265
+ if not indent:
266
+ return f"{line} - Length {len(line)}"
267
+ return (
268
+ f"{text} - Length {len(text)} plus a {indent}-column indent = {len(line)}"
269
+ )
270
+
256
271
  @staticmethod
257
272
  def _fix_last_captions_without_ending(caption_list):
258
273
  """Set end = start + 4s for trailing captions that were never ended."""
@@ -364,7 +379,11 @@ class SCCReader(BaseReader):
364
379
  if self._is_doubled_type(word) and word == self.last_command:
365
380
  if word in CUE_STARTING_COMMAND:
366
381
  self.double_starter = True
367
- self.last_command = ""
382
+ # A doubled PAC stays on record so that a Tab Offset following it is
383
+ # still recognised as belonging to a PAC and applied; clearing it
384
+ # would leave the offset looking like a stray duplicate and drop the
385
+ # indentation it carries.
386
+ self.last_command = word if self._is_pac_command(word) else ""
368
387
  return True
369
388
 
370
389
  if self._is_pac_command(word) and word in self.last_command: