pycaption 2.3.8__tar.gz → 2.3.10__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 (73) hide show
  1. {pycaption-2.3.8/pycaption.egg-info → pycaption-2.3.10}/PKG-INFO +1 -1
  2. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/writer.py +280 -15
  3. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/parser.py +4 -0
  4. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/reader.py +7 -1
  5. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/reader.py +16 -6
  6. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/specialized_collections.py +31 -19
  7. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/state_machines.py +48 -19
  8. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/srt.py +20 -10
  9. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/writer.py +262 -45
  10. {pycaption-2.3.8 → pycaption-2.3.10/pycaption.egg-info}/PKG-INFO +1 -1
  11. {pycaption-2.3.8 → pycaption-2.3.10}/setup.py +1 -1
  12. {pycaption-2.3.8 → pycaption-2.3.10}/tests/conftest.py +9 -0
  13. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/scc.py +134 -0
  14. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_base.py +82 -9
  15. pycaption-2.3.10/tests/test_dfxp.py +867 -0
  16. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_sami.py +20 -0
  17. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_scc.py +228 -3
  18. pycaption-2.3.10/tests/test_scc_conversion.py +297 -0
  19. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_srt.py +19 -0
  20. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_webvtt_conversion.py +596 -0
  21. pycaption-2.3.8/tests/test_dfxp.py +0 -441
  22. pycaption-2.3.8/tests/test_scc_conversion.py +0 -117
  23. {pycaption-2.3.8 → pycaption-2.3.10}/LICENSE +0 -0
  24. {pycaption-2.3.8 → pycaption-2.3.10}/MANIFEST.in +0 -0
  25. {pycaption-2.3.8 → pycaption-2.3.10}/README.rst +0 -0
  26. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/__init__.py +0 -0
  27. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/base.py +0 -0
  28. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/__init__.py +0 -0
  29. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/constants.py +0 -0
  30. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/extras.py +0 -0
  31. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/reader.py +0 -0
  32. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/exceptions.py +0 -0
  33. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/geometry.py +0 -0
  34. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/microdvd.py +0 -0
  35. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/__init__.py +0 -0
  36. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/constants.py +0 -0
  37. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/writer.py +0 -0
  38. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/__init__.py +0 -0
  39. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/constants.py +0 -0
  40. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/translator.py +0 -0
  41. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/writer.py +0 -0
  42. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/transcript.py +0 -0
  43. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/utils.py +0 -0
  44. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/__init__.py +0 -0
  45. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/constants.py +0 -0
  46. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/reader.py +0 -0
  47. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/SOURCES.txt +0 -0
  48. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/dependency_links.txt +0 -0
  49. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/requires.txt +0 -0
  50. {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/top_level.txt +0 -0
  51. {pycaption-2.3.8 → pycaption-2.3.10}/setup.cfg +0 -0
  52. {pycaption-2.3.8 → pycaption-2.3.10}/tests/__init__.py +0 -0
  53. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/__init__.py +0 -0
  54. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/dfxp.py +0 -0
  55. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/microdvd.py +0 -0
  56. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/sami.py +0 -0
  57. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/srt.py +0 -0
  58. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/translated_scc.py +0 -0
  59. {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/webvtt.py +0 -0
  60. {pycaption-2.3.8 → pycaption-2.3.10}/tests/mixins.py +0 -0
  61. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_bytes_input.py +0 -0
  62. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_dfxp_conversion.py +0 -0
  63. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_dfxp_extras.py +0 -0
  64. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_double_encoding.py +0 -0
  65. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_functions.py +0 -0
  66. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_geometry.py +0 -0
  67. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_microdvd.py +0 -0
  68. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_microdvd_conversion.py +0 -0
  69. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_sami_conversion.py +0 -0
  70. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_scc_translator.py +0 -0
  71. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_scc_writer.py +0 -0
  72. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_srt_conversion.py +0 -0
  73. {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_webvtt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.8
3
+ Version: 2.3.10
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -216,36 +216,275 @@ class DFXPWriter(BaseWriter):
216
216
  def _recreate_text(self, caption, dfxp, caption_set=None, lang=None):
217
217
  """Serialize all nodes of a caption into DFXP inline markup.
218
218
 
219
- Handles text nodes, line breaks (<br/>), and style spans.
219
+ Handles text nodes, line breaks (<br/>), and style spans. Nodes are
220
+ first split into runs sharing one region; a run whose region is not
221
+ the one already in effect is wrapped in a <span> carrying it, so text
222
+ renders at its own position instead of inheriting the one on the
223
+ enclosing <p>, and a <br/> between two nodes of one region stays
224
+ inside that region. A run holding no visible text takes no wrapper:
225
+ there is nothing for the region to place.
220
226
 
221
227
  :rtype: str
222
228
  """
223
229
  line = ""
224
230
  self._span_stack = []
231
+ paragraph = self.region_creator.get_positioning_info(lang, caption_set, caption)
232
+ paragraph_region, _ = paragraph
233
+
234
+ for region_id, region_attribs, nodes, wrappable in self._group_nodes_by_region(
235
+ caption, paragraph, caption_set, lang
236
+ ):
237
+ wrap = (
238
+ wrappable
239
+ and region_id != self._innermost_region(paragraph_region)
240
+ and self._holds_visible_text(nodes)
241
+ )
242
+ if wrap:
243
+ attrs = {"region": region_id}
244
+ if self.write_inline_positioning:
245
+ attrs.update(region_attribs)
246
+ attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
247
+ line += f"<span {attr_str}>"
225
248
 
226
- for node in caption.nodes:
227
- if node.type_ == CaptionNode.TEXT:
228
- line += escape(node.content)
249
+ for node in nodes:
250
+ if node.type_ == CaptionNode.TEXT:
251
+ line += escape(node.content)
229
252
 
230
- elif node.type_ == CaptionNode.BREAK:
231
- line = line.rstrip() + "<br/>\n "
253
+ elif node.type_ == CaptionNode.BREAK:
254
+ line = line.rstrip() + "<br/>\n "
232
255
 
233
- elif node.type_ == CaptionNode.STYLE:
234
- line = self._recreate_span(line, node, dfxp, caption_set, caption, lang)
256
+ elif node.type_ == CaptionNode.STYLE:
257
+ line = self._recreate_span(
258
+ line, node, dfxp, caption_set, caption, lang
259
+ )
260
+
261
+ if wrap:
262
+ line = line.rstrip() + "</span> "
235
263
 
236
264
  while self._span_stack:
237
- line = line.rstrip() + "</span> "
238
- self._span_stack.pop()
265
+ emitted, _ = self._span_stack.pop()
266
+ if emitted:
267
+ line = line.rstrip() + "</span> "
239
268
 
240
269
  return line.rstrip()
241
270
 
271
+ def _group_nodes_by_region(self, caption, paragraph, caption_set, lang):
272
+ """Split a caption's nodes into consecutive runs sharing one region.
273
+
274
+ Line breaks are neutral: a run absorbs one only when it continues
275
+ past it, so a break between two nodes of the same region ends up
276
+ inside that region's span rather than outside it. Breaks left over
277
+ between two runs form a run of their own, which is never wrapped.
278
+ A run is split again around any style tag whose partner falls
279
+ outside it, so one such tag costs only itself a wrapper.
280
+
281
+ :param paragraph: (region_id, positioning attributes) of the enclosing <p>
282
+ :return: (region_id, positioning_attributes, nodes, wrappable) per run
283
+ :rtype: list[tuple[str | None, dict, list[CaptionNode], bool]]
284
+ """
285
+ resolved, partners = self._resolve_node_regions(
286
+ caption, paragraph, caption_set, lang
287
+ )
288
+
289
+ groups = []
290
+ current = None
291
+ pending = []
292
+
293
+ for index, (region_id, region_attribs) in enumerate(resolved):
294
+ if region_id is None:
295
+ pending.append(index)
296
+ continue
297
+
298
+ if current and region_id == current[0]:
299
+ current[2].extend(pending)
300
+ else:
301
+ if current:
302
+ groups.append(current)
303
+ if pending:
304
+ groups.append((None, {}, pending))
305
+ current = [region_id, region_attribs, []]
306
+ pending = []
307
+ current[2].append(index)
308
+
309
+ if current:
310
+ groups.append(current)
311
+ if pending:
312
+ groups.append((None, {}, pending))
313
+
314
+ return [
315
+ (
316
+ region_id,
317
+ region_attribs,
318
+ [caption.nodes[index] for index in segment],
319
+ region_id is not None
320
+ and self._is_wrappable(segment, partners, caption.nodes),
321
+ )
322
+ for region_id, region_attribs, indices in groups
323
+ for segment in self._split_at_crossing_styles(
324
+ indices, partners, caption.nodes
325
+ )
326
+ ]
327
+
328
+ @staticmethod
329
+ def _holds_visible_text(nodes):
330
+ """Check whether a run has any text for a region to place.
331
+
332
+ A run of nothing but style tags and breaks renders nothing, so a
333
+ wrapper carrying its region would be an empty span.
334
+
335
+ :rtype: bool
336
+ """
337
+ return any(
338
+ node.type_ == CaptionNode.TEXT and node.content.strip() for node in nodes
339
+ )
340
+
341
+ @staticmethod
342
+ def _crosses_run(index, partners, nodes, run):
343
+ """Check whether the node at an index leaves the run it belongs to.
344
+
345
+ A style tag whose partner sits outside the run does: a span wrapping
346
+ it would cross that tag's own span and produce invalid XML. A
347
+ closing tag with no partner at all is the exception — nothing was
348
+ opened for it, so it writes no tag and crosses nothing.
349
+
350
+ :param run: the indices making up the run
351
+ :type run: set[int]
352
+ :rtype: bool
353
+ """
354
+ node = nodes[index]
355
+ if node.type_ != CaptionNode.STYLE:
356
+ return False
357
+
358
+ if not node.start and index not in partners:
359
+ return False
360
+
361
+ return partners.get(index) not in run
362
+
363
+ @classmethod
364
+ def _split_at_crossing_styles(cls, indices, partners, nodes):
365
+ """Split a run around style tags whose partner sits outside it.
366
+
367
+ A span wrapping such a tag would cross another span and produce
368
+ invalid XML, but only that tag is in the way — the nodes beside it
369
+ still deserve their own region, so they come back as runs of their
370
+ own instead of losing their position along with it.
371
+
372
+ :rtype: list[list[int]]
373
+ """
374
+ run = set(indices)
375
+ segments = [[]]
376
+
377
+ for index in indices:
378
+ if cls._crosses_run(index, partners, nodes, run):
379
+ segments += [[index], []]
380
+ else:
381
+ segments[-1].append(index)
382
+
383
+ return [segment for segment in segments if segment]
384
+
385
+ def _resolve_node_regions(self, caption, paragraph, caption_set, lang):
386
+ """Resolve the region every node of a caption belongs to.
387
+
388
+ A node's own layout wins; lacking one it takes the region in effect
389
+ around it — the innermost enclosing style span's, else the
390
+ paragraph's. For a closing style node that is its own opener's
391
+ region, so a span is never separated from its own end tag. Line
392
+ breaks belong to no region and resolve to None, as does a closing
393
+ tag with nothing open — it writes no tag at all. A region travels
394
+ with the attributes that describe it, so a span reinstating one can
395
+ also write it inline.
396
+
397
+ :param paragraph: (region_id, positioning attributes) of the <p>
398
+ :return: a (region_id, positioning_attributes) pair per node, plus a
399
+ map pairing the index of each style tag with its partner's
400
+ :rtype: tuple[list[tuple[str | None, dict]], dict]
401
+ """
402
+ creator = self.region_creator
403
+ resolved = []
404
+ partners = {}
405
+ open_styles = []
406
+
407
+ for index, node in enumerate(caption.nodes):
408
+ in_effect = open_styles[-1][1] if open_styles else paragraph
409
+
410
+ if node.type_ == CaptionNode.BREAK:
411
+ resolved.append((None, {}))
412
+
413
+ elif node.type_ == CaptionNode.TEXT:
414
+ own = (None, {})
415
+ if node.content.strip():
416
+ own = creator.get_node_positioning_info(node.layout_info)
417
+ resolved.append(own if own[0] else in_effect)
418
+
419
+ elif node.start:
420
+ if node.layout_info:
421
+ region = creator.get_positioning_info(
422
+ lang, caption_set, caption, node
423
+ )
424
+ else:
425
+ region = in_effect
426
+ open_styles.append((index, region))
427
+ resolved.append(region)
428
+
429
+ elif open_styles:
430
+ opener, _ = open_styles.pop()
431
+ partners[opener] = index
432
+ partners[index] = opener
433
+ resolved.append(in_effect)
434
+
435
+ else:
436
+ resolved.append((None, {}))
437
+
438
+ return resolved, partners
439
+
440
+ @classmethod
441
+ def _is_wrappable(cls, indices, partners, nodes):
442
+ """Check whether a run of nodes can take a <span> holding its region.
443
+
444
+ No style tag in the run may leave it, or the wrapping span would
445
+ cross a style span and produce invalid XML — which is what rules out
446
+ a tag split off on its own. A run that is nothing but one style span
447
+ already writes any region of its own on that span, so it needs no
448
+ wrapper.
449
+
450
+ :rtype: bool
451
+ """
452
+ run = set(indices)
453
+ for index in indices:
454
+ if cls._crosses_run(index, partners, nodes, run):
455
+ return False
456
+
457
+ first = nodes[indices[0]]
458
+
459
+ return not (
460
+ first.type_ == CaptionNode.STYLE
461
+ and first.start
462
+ and partners.get(indices[0]) == indices[-1]
463
+ )
464
+
465
+ def _innermost_region(self, default):
466
+ """Return the region in effect at the current point in the caption.
467
+
468
+ The innermost open span carrying a region wins; with no such span,
469
+ the region on the enclosing <p> applies.
470
+
471
+ :param default: region of the enclosing <p>
472
+ :rtype: str
473
+ """
474
+ for _, region_id in reversed(self._span_stack):
475
+ if region_id:
476
+ return region_id
477
+
478
+ return default
479
+
242
480
  def _recreate_span(
243
481
  self, line, node, dfxp, caption_set=None, caption=None, lang=None
244
482
  ):
245
483
  """Open or close a <span> element for a style node.
246
484
 
247
485
  Supports nested spans via a stack. Opening pushes onto the stack;
248
- closing pops the most recent span.
486
+ closing pops the most recent span. Each entry records whether a tag
487
+ was actually emitted and which region it carries, if any.
249
488
 
250
489
  :param line: the accumulated markup string so far
251
490
  :type line: str
@@ -254,6 +493,7 @@ class DFXPWriter(BaseWriter):
254
493
  """
255
494
  if node.start:
256
495
  attrs = _recreate_style(node.content, dfxp)
496
+ region_id = None
257
497
  if node.layout_info:
258
498
  region_id, region_attribs = self.region_creator.get_positioning_info(
259
499
  lang, caption_set, caption, node
@@ -265,13 +505,13 @@ class DFXPWriter(BaseWriter):
265
505
  if attrs:
266
506
  attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
267
507
  line += f"<span {attr_str}>"
268
- self._span_stack.append(True)
508
+ self._span_stack.append((True, region_id))
269
509
  else:
270
- self._span_stack.append(False)
510
+ self._span_stack.append((False, None))
271
511
 
272
512
  else:
273
513
  if self._span_stack:
274
- had_span = self._span_stack.pop()
514
+ had_span, _ = self._span_stack.pop()
275
515
  if had_span:
276
516
  line = line.rstrip() + "</span> "
277
517
 
@@ -331,7 +571,12 @@ class RegionCreator:
331
571
  """Create <region> tags in the <layout> section for each Layout.
332
572
 
333
573
  Skips Layout objects that have no positioning data (no origin,
334
- extent, padding, alignment, or writing_direction).
574
+ extent, padding, alignment, or writing_direction). A line alignment
575
+ on its own is not positioning data: it is what a WebVTT ``line``
576
+ setting leaves behind when its value yields no line to place the cue
577
+ at — ``line:auto,start``, or a value the grammar discards outright —
578
+ so the cue keeps the default placement rather than taking a vertical
579
+ one from the qualifier alone.
335
580
 
336
581
  :param unique_layouts: iterable of geometry.Layout instances
337
582
  :type dfxp: BeautifulSoup
@@ -450,6 +695,26 @@ class RegionCreator:
450
695
 
451
696
  return region_id, positioning_attributes
452
697
 
698
+ def get_node_positioning_info(self, layout_info):
699
+ """Return (region_id, positioning_attributes) for a node's own layout.
700
+
701
+ Unlike get_positioning_info, this neither cascades to the caption and
702
+ the caption set nor falls back to the default region: a layout without
703
+ a region of its own yields (None, {}).
704
+
705
+ :type layout_info: geometry.Layout | None
706
+ :rtype: tuple[str | None, dict]
707
+ """
708
+ region_id = self._region_map.get(layout_info)
709
+ if not region_id:
710
+ return None, {}
711
+
712
+ self._assigned_region_ids.add(region_id)
713
+
714
+ return region_id, _convert_layout_to_attributes(
715
+ layout_info, self._fallback_alignment
716
+ )
717
+
453
718
  def cleanup_regions(self):
454
719
  """Remove <region> tags that were never assigned to any element."""
455
720
  layout_tag = self._dfxp.find("layout")
@@ -174,6 +174,10 @@ class SAMIParser(HTMLParser):
174
174
  :rtype: str | None
175
175
  """
176
176
  for attr, value in attrs:
177
+ if value is None:
178
+ # A valueless attribute (e.g. a bare ``class``) carries no
179
+ # language information; skip it rather than crashing.
180
+ continue
177
181
  if attr.lower() == "lang":
178
182
  return value[:2]
179
183
  if attr.lower() == "class":
@@ -142,7 +142,13 @@ class SAMIReader(BaseReader):
142
142
  raise CaptionReadTimingError(
143
143
  f"Missing start time on the following line: {p.parent}."
144
144
  )
145
- milliseconds = int(float(start_str))
145
+ try:
146
+ milliseconds = int(float(start_str))
147
+ except ValueError:
148
+ raise CaptionReadTimingError(
149
+ f"Invalid start time {start_str!r} on the following "
150
+ f"line: {p.parent}."
151
+ )
146
152
  start = milliseconds * 1000
147
153
 
148
154
  self._backfill_end_times(captions, start)
@@ -100,6 +100,7 @@ from .constants import (
100
100
  SPECIAL_CHARS,
101
101
  )
102
102
  from .specialized_collections import (
103
+ PUNCTUATION_PREFIXES,
103
104
  CaptionCreator,
104
105
  InstructionNodeCreator,
105
106
  NotifyingDict,
@@ -302,23 +303,28 @@ class SCCReader(BaseReader):
302
303
  word = word.strip()
303
304
  if len(word) == 4:
304
305
  # Look ahead for the next command, skipping the duplicate
305
- # that SCC uses for error-correction (same word repeated).
306
+ # that SCC uses for error-correction (same word repeated), to
307
+ # tell whether it decodes to punctuation.
306
308
  next_idx = idx + 1
307
309
  if next_idx < len(word_list) and word_list[next_idx].strip() == word:
308
310
  next_idx += 1
309
311
  next_command = (
310
312
  word_list[next_idx] if next_idx < len(word_list) else None
311
313
  )
312
- self._translate_word(word=word, next_command=next_command)
314
+ next_is_punctuation = (
315
+ next_command is not None
316
+ and next_command[:2] in PUNCTUATION_PREFIXES
317
+ )
318
+ self._translate_word(word=word, next_is_punctuation=next_is_punctuation)
313
319
 
314
- def _translate_word(self, word, next_command=None):
320
+ def _translate_word(self, word, next_is_punctuation=False):
315
321
  """Dispatch a single 4-char hex word as command, special char, or text."""
316
322
  if self._handle_double_command(word):
317
323
  # count frames for timing
318
324
  self.time_translator._frames += 1
319
325
  return
320
326
  if word in COMMANDS or self._is_pac_command(word):
321
- self._translate_command(word=word, next_command=next_command)
327
+ self._translate_command(word=word, next_is_punctuation=next_is_punctuation)
322
328
 
323
329
  # second, check if word is a special character
324
330
  elif word in SPECIAL_CHARS:
@@ -392,7 +398,7 @@ class SCCReader(BaseReader):
392
398
 
393
399
  _ROLL_UP_DEPTH = {"9425": 2, "9426": 3, "94a7": 4}
394
400
 
395
- def _translate_command(self, word, next_command=None):
401
+ def _translate_command(self, word, next_is_punctuation=False):
396
402
  """Route a CEA-608 control command to the appropriate handler."""
397
403
  if word == "9420":
398
404
  self._cmd_pop_on()
@@ -409,7 +415,11 @@ class SCCReader(BaseReader):
409
415
  elif word == "942c":
410
416
  self._cmd_erase_displayed()
411
417
  else:
412
- self.buffer.interpret_command(command=word, next_command=next_command)
418
+ self.buffer.interpret_command(
419
+ command=word,
420
+ next_is_punctuation=next_is_punctuation,
421
+ is_paint_on=self.buffer_dict.active_key == "paint",
422
+ )
413
423
 
414
424
  def _cmd_pop_on(self):
415
425
  """Handle Resume Caption Loading [RCL] - switch to pop-on mode."""
@@ -32,7 +32,7 @@ from .constants import (
32
32
  PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
33
33
 
34
34
  # First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
35
- _PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
35
+ PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
36
36
 
37
37
 
38
38
  class PreCaption:
@@ -412,7 +412,7 @@ class InstructionNodeCreator:
412
412
  # only remaining possibility is plain text
413
413
  return "plaintext"
414
414
 
415
- def interpret_command(self, command, next_command=None):
415
+ def interpret_command(self, command, next_is_punctuation=False, is_paint_on=False):
416
416
  """Given a command determines whether to turn italics on or off,
417
417
  or to set the positioning
418
418
 
@@ -420,9 +420,15 @@ class InstructionNodeCreator:
420
420
 
421
421
  :type command: str
422
422
  or a PAC_TAB_OFFSET_COMMANDS
423
- :type next_command: the command that follows next
423
+ :type next_is_punctuation: bool
424
+ :param next_is_punctuation: whether the command right after this one
425
+ decodes to punctuation (comma, period, etc.)
426
+ :type is_paint_on: bool
427
+ :param is_paint_on: whether this command is being interpreted while
428
+ in paint-on mode, where a row+1 jump paired with a large column
429
+ jump indicates an unrelated, independently-positioned region
424
430
  """
425
- self._update_positioning(command)
431
+ self._update_positioning(command, is_paint_on)
426
432
 
427
433
  if command == "94a1":
428
434
  self.handle_backspace("94a1")
@@ -434,7 +440,7 @@ class InstructionNodeCreator:
434
440
  self._handle_style_command(command)
435
441
 
436
442
  if command in MID_ROW_CODES and command not in PAC_TAB_OFFSET_COMMANDS:
437
- self._handle_mid_row_spacing(next_command)
443
+ self._handle_mid_row_spacing(next_is_punctuation)
438
444
 
439
445
  def _handle_background_color(self):
440
446
  """Strip trailing space before a background color code (CEA-608 rule)."""
@@ -460,9 +466,7 @@ class InstructionNodeCreator:
460
466
  if self.last_style is not None and self.last_style != "italics off":
461
467
  return
462
468
  self._emit_pending_breaks(position)
463
- self._collection.append(
464
- _InstructionNode.create_italics_style(position)
465
- )
469
+ self._collection.append(_InstructionNode.create_italics_style(position))
466
470
  self.last_style = "italics on"
467
471
 
468
472
  def _close_italics(self, position):
@@ -482,24 +486,29 @@ class InstructionNodeCreator:
482
486
  if not self._position_tracer.is_linebreak_required():
483
487
  return
484
488
  for _ in range(self._position_tracer._breaks_required):
485
- self._collection.append(
486
- _InstructionNode.create_break(position=position)
487
- )
489
+ self._collection.append(_InstructionNode.create_break(position=position))
488
490
  self._position_tracer.acknowledge_linebreak_consumed()
489
491
 
490
- def _handle_mid_row_spacing(self, next_command):
492
+ def _handle_mid_row_spacing(self, next_is_punctuation):
491
493
  """Insert spacing around mid-row code style transitions."""
492
- next_is_punctuation = next_command and next_command[:2] in _PUNCTUATION_PREFIXES
494
+ if self._position_tracer.is_repositioning_required():
495
+ # A repositioning is already pending with no text written at the
496
+ # current position — that position is about to be abandoned, so
497
+ # padding it with a decorative space would wrongly consume the
498
+ # pending repositioning before the real content arrives.
499
+ return
493
500
  prev_text_node = self.get_previous_text_node()
494
501
  if not prev_text_node:
495
502
  return
496
503
  prev_node_is_break = any(
497
504
  x.is_explicit_break()
498
- for x in self._collection[self._collection.index(prev_text_node):]
505
+ for x in self._collection[self._collection.index(prev_text_node) :]
499
506
  )
500
- if (prev_node_is_break
501
- or prev_text_node.text[-1].isspace()
502
- or next_is_punctuation):
507
+ if (
508
+ prev_node_is_break
509
+ or prev_text_node.text[-1].isspace()
510
+ or next_is_punctuation
511
+ ):
503
512
  return
504
513
 
505
514
  if self.last_style == "italics off":
@@ -507,10 +516,11 @@ class InstructionNodeCreator:
507
516
  else:
508
517
  prev_text_node.text = prev_text_node.text + " "
509
518
 
510
- def _update_positioning(self, command):
519
+ def _update_positioning(self, command, is_paint_on=False):
511
520
  """Sets the positioning information to use for the next nodes
512
521
 
513
522
  :type command: str
523
+ :type is_paint_on: bool
514
524
  """
515
525
  is_offset = False
516
526
  if command in PAC_TAB_OFFSET_COMMANDS:
@@ -529,7 +539,9 @@ class InstructionNodeCreator:
529
539
  offset_after_break = is_offset and self.has_break_before(self._collection)
530
540
  if not offset_after_break:
531
541
  # Tab offsets after line breaks will be ignored to avoid repositioning
532
- self._position_tracer.update_positioning(positioning)
542
+ self._position_tracer.update_positioning(
543
+ positioning, column_jump_forces_reposition=is_paint_on
544
+ )
533
545
 
534
546
  def __iter__(self):
535
547
  return iter(_format_italics(self._collection))
@@ -25,17 +25,39 @@ class _PositioningTracker:
25
25
  # next positioning is actually a Tab Offset
26
26
  self._last_column = None
27
27
 
28
- def update_positioning(self, positioning):
28
+ def update_positioning(self, positioning, column_jump_forces_reposition=False):
29
29
  """Being notified of a position change, updates the internal state,
30
30
  to as to be able to tell if it was a trivial change (a simple line
31
31
  break) or not.
32
32
 
33
33
  Strategy:
34
- - Small jumps (1-3 rows): Use line breaks to preserve visual spacing
35
- - Large jumps (4+ rows): Use repositioning (creates new cue)
34
+ - A jump to the very next row (row + 1): Use a single line break —
35
+ this is simple text wrapping onto the next line. Exception: if
36
+ paired with a column jump bigger than a tab offset in paint-on
37
+ mode (see ``column_jump_forces_reposition``), it's treated as a
38
+ repositioning instead, since paint-on is the only mode that can
39
+ display independent, simultaneously-timed regions one row apart.
40
+ - Any other row jump (a skipped row, or a jump backwards): Use
41
+ repositioning (creates new cue). A skipped row is not an
42
+ intentional blank line to preserve — CEA-608 PACs only declare an
43
+ absolute row position; nothing in the spec says a gap between two
44
+ rows must be rendered as a blank line downstream, and WebVTT/HTML
45
+ renderers draw a real, often background-filled line for each
46
+ break, which previously produced a visible black bar between two
47
+ lines of text that were never meant to have a gap between them.
36
48
 
37
49
  :type positioning: tuple[int]
38
50
  :param positioning: a tuple (row, col)
51
+
52
+ :type column_jump_forces_reposition: bool
53
+ :param column_jump_forces_reposition: when True, a row+1 jump
54
+ paired with a column jump larger than a tab offset (>3 columns)
55
+ is treated as a repositioning rather than a break. Only
56
+ paint-on mode can display independent, simultaneously-timed
57
+ regions at unrelated columns, so callers should only set this
58
+ for paint-on buffers — pop-on and roll-up buffers legitimately
59
+ use large column shifts between buffered/wrapped lines of the
60
+ same cue.
39
61
  """
40
62
  current = self._positions[-1]
41
63
 
@@ -51,24 +73,31 @@ class _PositioningTracker:
51
73
  new_row, new_col = positioning
52
74
  is_tab_offset = new_row == row and col + 1 <= new_col <= col + 3
53
75
 
54
- # Threshold for when to use breaks vs repositioning
55
- # Jumps of 4+ rows will trigger repositioning instead of adding breaks
56
- max_breaks_threshold = 3
57
-
58
76
  # Handle row jumps
59
77
  if new_row > row:
60
- row_diff = new_row - row
61
-
62
- # Small jumps (1-3 rows): Use line breaks to preserve visual spacing
63
- if row_diff <= max_breaks_threshold:
78
+ is_large_column_jump = (
79
+ column_jump_forces_reposition and abs(new_col - col) > 3
80
+ )
81
+
82
+ # A jump to the very next row: use a line break. But if a
83
+ # repositioning was already pending and unconsumed (no text was
84
+ # ever written at the current position), this row jump is
85
+ # continuing that same unresolved position change, not wrapping
86
+ # text — so it must stay a repositioning rather than become a
87
+ # break. Likewise, in paint-on mode, pairing the row+1 jump
88
+ # with a column jump bigger than a tab offset means the new PAC
89
+ # almost certainly targets an unrelated, independently-positioned
90
+ # region rather than wrapping the current text.
91
+ if (
92
+ new_row == row + 1
93
+ and not self._repositioning_required
94
+ and not is_large_column_jump
95
+ ):
64
96
  self._positions.append((new_row, col))
65
- # Add breaks equal to row difference
66
- # Row N -> N+1: 1 break
67
- # Row N -> N+2: 2 breaks (preserves 1 blank line)
68
- # Row N -> N+3: 3 breaks (preserves 2 blank lines)
69
- self._breaks_required = row_diff
97
+ self._breaks_required = 1
70
98
  self._last_column = new_col
71
- # Large jumps (4+ rows): Use repositioning (new cue)
99
+ # A skipped row, a jump backwards, or a row+1 jump with a large
100
+ # paint-on column jump: use repositioning (new cue)
72
101
  else:
73
102
  # Reset position - this triggers repositioning
74
103
  self._positions = [positioning]
@@ -165,7 +194,7 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
165
194
  except CaptionReadSyntaxError:
166
195
  return self.default
167
196
 
168
- def update_positioning(self, positioning):
197
+ def update_positioning(self, positioning, column_jump_forces_reposition=False):
169
198
  """If called, sets this positioning as the default, then delegates
170
199
  to the super class.
171
200
 
@@ -175,4 +204,4 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
175
204
  if positioning:
176
205
  self.default = positioning
177
206
 
178
- super().update_positioning(positioning)
207
+ super().update_positioning(positioning, column_jump_forces_reposition)