pycaption 2.3.9__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 (72) hide show
  1. {pycaption-2.3.9/pycaption.egg-info → pycaption-2.3.10}/PKG-INFO +1 -1
  2. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/dfxp/writer.py +280 -15
  3. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/webvtt/writer.py +262 -45
  4. {pycaption-2.3.9 → pycaption-2.3.10/pycaption.egg-info}/PKG-INFO +1 -1
  5. {pycaption-2.3.9 → pycaption-2.3.10}/setup.py +1 -1
  6. {pycaption-2.3.9 → pycaption-2.3.10}/tests/conftest.py +1 -0
  7. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/scc.py +17 -0
  8. pycaption-2.3.10/tests/test_dfxp.py +867 -0
  9. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_scc_conversion.py +22 -0
  10. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_webvtt_conversion.py +596 -0
  11. pycaption-2.3.9/tests/test_dfxp.py +0 -441
  12. {pycaption-2.3.9 → pycaption-2.3.10}/LICENSE +0 -0
  13. {pycaption-2.3.9 → pycaption-2.3.10}/MANIFEST.in +0 -0
  14. {pycaption-2.3.9 → pycaption-2.3.10}/README.rst +0 -0
  15. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/__init__.py +0 -0
  16. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/base.py +0 -0
  17. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/dfxp/__init__.py +0 -0
  18. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/dfxp/constants.py +0 -0
  19. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/dfxp/extras.py +0 -0
  20. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/dfxp/reader.py +0 -0
  21. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/exceptions.py +0 -0
  22. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/geometry.py +0 -0
  23. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/microdvd.py +0 -0
  24. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/sami/__init__.py +0 -0
  25. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/sami/constants.py +0 -0
  26. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/sami/parser.py +0 -0
  27. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/sami/reader.py +0 -0
  28. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/sami/writer.py +0 -0
  29. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/__init__.py +0 -0
  30. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/constants.py +0 -0
  31. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/reader.py +0 -0
  32. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/specialized_collections.py +0 -0
  33. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/state_machines.py +0 -0
  34. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/translator.py +0 -0
  35. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/scc/writer.py +0 -0
  36. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/srt.py +0 -0
  37. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/transcript.py +0 -0
  38. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/utils.py +0 -0
  39. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/webvtt/__init__.py +0 -0
  40. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/webvtt/constants.py +0 -0
  41. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption/webvtt/reader.py +0 -0
  42. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption.egg-info/SOURCES.txt +0 -0
  43. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption.egg-info/dependency_links.txt +0 -0
  44. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption.egg-info/requires.txt +0 -0
  45. {pycaption-2.3.9 → pycaption-2.3.10}/pycaption.egg-info/top_level.txt +0 -0
  46. {pycaption-2.3.9 → pycaption-2.3.10}/setup.cfg +0 -0
  47. {pycaption-2.3.9 → pycaption-2.3.10}/tests/__init__.py +0 -0
  48. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/__init__.py +0 -0
  49. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/dfxp.py +0 -0
  50. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/microdvd.py +0 -0
  51. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/sami.py +0 -0
  52. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/srt.py +0 -0
  53. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/translated_scc.py +0 -0
  54. {pycaption-2.3.9 → pycaption-2.3.10}/tests/fixtures/webvtt.py +0 -0
  55. {pycaption-2.3.9 → pycaption-2.3.10}/tests/mixins.py +0 -0
  56. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_base.py +0 -0
  57. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_bytes_input.py +0 -0
  58. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_dfxp_conversion.py +0 -0
  59. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_dfxp_extras.py +0 -0
  60. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_double_encoding.py +0 -0
  61. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_functions.py +0 -0
  62. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_geometry.py +0 -0
  63. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_microdvd.py +0 -0
  64. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_microdvd_conversion.py +0 -0
  65. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_sami.py +0 -0
  66. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_sami_conversion.py +0 -0
  67. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_scc.py +0 -0
  68. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_scc_translator.py +0 -0
  69. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_scc_writer.py +0 -0
  70. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_srt.py +0 -0
  71. {pycaption-2.3.9 → pycaption-2.3.10}/tests/test_srt_conversion.py +0 -0
  72. {pycaption-2.3.9 → 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.9
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")