pycaption 2.3.12__tar.gz → 2.3.13__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.12/pycaption.egg-info → pycaption-2.3.13}/PKG-INFO +1 -1
  2. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/extras.py +2 -1
  3. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/reader.py +2 -1
  4. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/writer.py +4 -1
  5. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/reader.py +8 -2
  6. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/writer.py +2 -1
  7. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/reader.py +29 -12
  8. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/specialized_collections.py +90 -2
  9. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/state_machines.py +37 -0
  10. pycaption-2.3.13/pycaption/utils.py +26 -0
  11. {pycaption-2.3.12 → pycaption-2.3.13/pycaption.egg-info}/PKG-INFO +1 -1
  12. {pycaption-2.3.12 → pycaption-2.3.13}/setup.py +1 -1
  13. {pycaption-2.3.12 → pycaption-2.3.13}/tests/conftest.py +18 -0
  14. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/dfxp.py +18 -0
  15. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/sami.py +35 -0
  16. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/scc.py +278 -0
  17. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_dfxp.py +45 -0
  18. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_dfxp_extras.py +20 -0
  19. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_sami.py +63 -1
  20. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc.py +178 -8
  21. pycaption-2.3.12/pycaption/utils.py +0 -13
  22. {pycaption-2.3.12 → pycaption-2.3.13}/LICENSE +0 -0
  23. {pycaption-2.3.12 → pycaption-2.3.13}/MANIFEST.in +0 -0
  24. {pycaption-2.3.12 → pycaption-2.3.13}/README.rst +0 -0
  25. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/__init__.py +0 -0
  26. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/base.py +0 -0
  27. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/__init__.py +0 -0
  28. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/constants.py +0 -0
  29. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/exceptions.py +0 -0
  30. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/geometry.py +0 -0
  31. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/microdvd.py +0 -0
  32. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/__init__.py +0 -0
  33. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/constants.py +0 -0
  34. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/parser.py +0 -0
  35. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/__init__.py +0 -0
  36. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/constants.py +0 -0
  37. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/translator.py +0 -0
  38. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/writer.py +0 -0
  39. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/srt.py +0 -0
  40. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/transcript.py +0 -0
  41. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/__init__.py +0 -0
  42. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/constants.py +0 -0
  43. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/reader.py +0 -0
  44. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/writer.py +0 -0
  45. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/SOURCES.txt +0 -0
  46. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/dependency_links.txt +0 -0
  47. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/requires.txt +0 -0
  48. {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/top_level.txt +0 -0
  49. {pycaption-2.3.12 → pycaption-2.3.13}/setup.cfg +0 -0
  50. {pycaption-2.3.12 → pycaption-2.3.13}/tests/__init__.py +0 -0
  51. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/__init__.py +0 -0
  52. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/microdvd.py +0 -0
  53. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/srt.py +0 -0
  54. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/translated_scc.py +0 -0
  55. {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/webvtt.py +0 -0
  56. {pycaption-2.3.12 → pycaption-2.3.13}/tests/mixins.py +0 -0
  57. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_base.py +0 -0
  58. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_bytes_input.py +0 -0
  59. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_dfxp_conversion.py +0 -0
  60. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_double_encoding.py +0 -0
  61. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_functions.py +0 -0
  62. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_geometry.py +0 -0
  63. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_microdvd.py +0 -0
  64. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_microdvd_conversion.py +0 -0
  65. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_sami_conversion.py +0 -0
  66. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc_conversion.py +0 -0
  67. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc_translator.py +0 -0
  68. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc_writer.py +0 -0
  69. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_srt.py +0 -0
  70. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_srt_conversion.py +0 -0
  71. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_webvtt.py +0 -0
  72. {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_webvtt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.12
3
+ Version: 2.3.13
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -10,6 +10,7 @@ from xml.sax.saxutils import escape
10
10
  from bs4 import BeautifulSoup
11
11
 
12
12
  from ..base import BaseWriter, CaptionNode, merge_concurrent_captions
13
+ from ..utils import encode_leading_nbsp
13
14
  from .constants import DFXP_DEFAULT_REGION
14
15
  from .writer import DFXPWriter
15
16
 
@@ -204,7 +205,7 @@ class LegacyDFXPWriter(BaseWriter):
204
205
  start = caption.format_start()
205
206
  end = caption.format_end()
206
207
  p = dfxp.new_tag("p", begin=start, end=end)
207
- p.string = self._recreate_text(caption, dfxp)
208
+ p.string = encode_leading_nbsp(self._recreate_text(caption, dfxp))
208
209
 
209
210
  if dfxp.find("style", {"xml:id": "p"}):
210
211
  p["style"] = "p"
@@ -48,7 +48,8 @@ from .constants import (
48
48
  )
49
49
 
50
50
 
51
- _LEADING_WHITESPACE_RE = re.compile("^(?:[\n\r]+\\s*)?(.+)")
51
+ # Pretty-print indentation only: \s would also eat U+00A0 line indentation.
52
+ _LEADING_WHITESPACE_RE = re.compile("^(?:[\n\r]+[ \t\n\r]*)?(.+)")
52
53
 
53
54
  _DFXP_WRITING_MODE_MAP = {
54
55
  "tbrl": WritingDirectionEnum.VERTICAL_RL,
@@ -13,6 +13,7 @@ from bs4 import BeautifulSoup
13
13
 
14
14
  from ..base import BaseWriter, CaptionNode
15
15
  from ..geometry import HorizontalAlignmentEnum, LineAlignmentEnum, WritingDirectionEnum
16
+ from ..utils import encode_leading_nbsp
16
17
  from .constants import (
17
18
  DFXP_ATTR_XML_ID,
18
19
  DFXP_ATTR_XML_LANG,
@@ -206,7 +207,9 @@ class DFXPWriter(BaseWriter):
206
207
  start = caption.format_start()
207
208
  end = caption.format_end()
208
209
  p = dfxp.new_tag("p", begin=start, end=end)
209
- p.string = self._recreate_text(caption, dfxp, caption_set, lang)
210
+ p.string = encode_leading_nbsp(
211
+ self._recreate_text(caption, dfxp, caption_set, lang)
212
+ )
210
213
 
211
214
  if dfxp.find("style", {DFXP_ATTR_XML_ID: "p"}):
212
215
  p["style"] = "p"
@@ -4,6 +4,8 @@ Parses SAMI documents into pycaption CaptionSet objects, handling CSS
4
4
  styles, margin-based positioning, and multi-language content.
5
5
  """
6
6
 
7
+ import re
8
+
7
9
  from bs4 import BeautifulSoup, NavigableString
8
10
 
9
11
  from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet
@@ -14,6 +16,9 @@ from .parser import SAMIParser
14
16
 
15
17
  _TAG_TO_STYLE = {"i": "italics", "b": "bold", "u": "underline"}
16
18
 
19
+ # A line break plus the pretty-print indentation of the closing tag.
20
+ _TRAILING_WHITESPACE_RE = re.compile("[\n\r][ \t\n\r]*$")
21
+
17
22
 
18
23
  class SAMIReader(BaseReader):
19
24
  """Reads SAMI caption files into a CaptionSet.
@@ -203,8 +208,9 @@ class SAMIReader(BaseReader):
203
208
  if isinstance(tag, NavigableString):
204
209
  tag_text = str(tag)
205
210
  if tag_text and tag_text[0] in "\n\r":
206
- tag_text = tag_text.lstrip()
207
- tag_text = tag_text.rstrip("\n\r")
211
+ # Not a bare lstrip(): U+00A0 is line indentation, not markup.
212
+ tag_text = tag_text.lstrip(" \t\n\r")
213
+ tag_text = _TRAILING_WHITESPACE_RE.sub("", tag_text)
208
214
  if not tag_text:
209
215
  return
210
216
  self.line.append(CaptionNode.create_text(tag_text, inherit_from))
@@ -11,6 +11,7 @@ from bs4 import BeautifulSoup
11
11
 
12
12
  from ..base import BaseWriter, CaptionNode
13
13
  from ..geometry import HorizontalAlignmentEnum
14
+ from ..utils import encode_leading_nbsp
14
15
  from .constants import HORIZONTAL_ALIGNMENT_MAP, SAMI_BASE_MARKUP
15
16
 
16
17
 
@@ -104,7 +105,7 @@ class SAMIWriter(BaseWriter):
104
105
  p["style"] = p_style
105
106
 
106
107
  p["class"] = self._recreate_p_lang(caption, lang, captions)
107
- p.string = self._recreate_text(caption.nodes)
108
+ p.string = encode_leading_nbsp(self._recreate_text(caption.nodes))
108
109
 
109
110
  sync.append(p)
110
111
 
@@ -112,6 +112,10 @@ from .state_machines import DefaultProvidingPositionTracker
112
112
 
113
113
  _TIMECODE_RE = re.compile(r"\d{2}:\d{2}:\d{2}[:;](\d{1,2})")
114
114
 
115
+ # Appended to a too-long line whose text kept going on the row with no PAC or
116
+ # row change of its own. Matched downstream, so the wording is part of the API.
117
+ ROW_CONTINUATION_SUFFIX = " (continues on the row without a row change)"
118
+
115
119
 
116
120
  class SCCReader(BaseReader):
117
121
  """Converts a given unicode string to a CaptionSet.
@@ -243,12 +247,18 @@ class SCCReader(BaseReader):
243
247
 
244
248
  def _validate_line_lengths(self):
245
249
  """Raise CaptionLineLengthError if any line exceeds 32 characters."""
246
- violations = [
247
- f"around {cap.format_start()} - {self._describe_line_length(line)}"
248
- for cap in (c.to_real_caption() for c in self.caption_stash._collection)
249
- for line in cap.get_text().split("\n")
250
- if len(line) > 32
251
- ]
250
+ violations = []
251
+ for precap in self.caption_stash._collection:
252
+ cap = precap.to_real_caption()
253
+ raw = "".join(cap.get_text_nodes())
254
+ # get_text() strips the caption, dropping any lines it opens with
255
+ skipped = raw[: len(raw) - len(raw.lstrip())].count("\n")
256
+ for index, line in enumerate(cap.get_text().split("\n")):
257
+ if len(line) > 32:
258
+ description = self._describe_line_length(
259
+ line, (index + skipped) in precap.continued_rows
260
+ )
261
+ violations.append(f"around {cap.format_start()} - {description}")
252
262
  if violations:
253
263
  raise CaptionLineLengthError(
254
264
  "32 character limit for caption cue in scc file.\n"
@@ -256,17 +266,23 @@ class SCCReader(BaseReader):
256
266
  )
257
267
 
258
268
  @staticmethod
259
- def _describe_line_length(line):
269
+ def _describe_line_length(line, continues_row=False):
260
270
  """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.
271
+ indentation that places it, which can be what pushes it off the row,
272
+ and naming a missing PAC or row change when that is what joined it.
262
273
  """
263
274
  text = line.lstrip("\xa0")
264
275
  indent = len(line) - len(text)
265
276
  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
- )
277
+ description = f"{line} - Length {len(line)}"
278
+ else:
279
+ description = (
280
+ f"{text} - Length {len(text)} plus a {indent}-column indent "
281
+ f"= {len(line)}"
282
+ )
283
+ if continues_row:
284
+ description += ROW_CONTINUATION_SUFFIX
285
+ return description
270
286
 
271
287
  @staticmethod
272
288
  def _fix_last_captions_without_ending(caption_list):
@@ -312,6 +328,7 @@ class SCCReader(BaseReader):
312
328
  parts = r.findall(line.lower())
313
329
 
314
330
  self.time_translator.start_at(parts[0][0])
331
+ self.position_tracker.start_burst()
315
332
  word_list = parts[0][2].split(" ")
316
333
 
317
334
  for idx, word in enumerate(word_list):
@@ -53,6 +53,9 @@ class PreCaption:
53
53
  self.nodes = []
54
54
  self.style = {}
55
55
  self.layout_info = None
56
+ # Indices of the lines whose text continued on the row without a PAC
57
+ # or row change, for the line length check to name the cause
58
+ self.continued_rows = set()
56
59
 
57
60
  def to_real_caption(self):
58
61
  """Convert this mutable holder into an immutable Caption instance.
@@ -274,6 +277,7 @@ class CaptionCreator:
274
277
  _leftmost_line_starts(instructions) if caption_mode == "pop_on" else []
275
278
  )
276
279
  cue_index = 0
280
+ line_index = 0
277
281
  indenter = _LineIndenter(enabled=bool(leftmost_columns))
278
282
 
279
283
  for instruction in instructions:
@@ -289,8 +293,14 @@ class CaptionCreator:
289
293
  self._still_editing.append(caption)
290
294
  cue_index += 1
291
295
  indenter.start_cue()
296
+ line_index = 0
292
297
  continue
293
298
 
299
+ if instruction.is_explicit_break():
300
+ line_index += 1
301
+ elif instruction.continues_row:
302
+ caption.continued_rows.add(line_index)
303
+
294
304
  text = indenter.text_for(instruction, position)
295
305
  _append_caption_node(caption, instruction, text, position)
296
306
 
@@ -381,17 +391,39 @@ class InstructionNodeCreator:
381
391
  self._collection.append(node)
382
392
  self._position_tracer.acknowledge_position_changed()
383
393
 
394
+ # Where a continuation is recorded: on the overwrite marker if the text
395
+ # lands on columns a PAC pointed back over, so a row sent again in full
396
+ # can drop it without dropping one of text arriving later
397
+ continued = node
384
398
  overlap = self._position_tracer.consume_pending_overlap()
385
399
  if overlap and self._mark_overwrite(overlap, current_position):
400
+ continued = self._collection[-1]
386
401
  node = _InstructionNode.create_text(current_position)
387
402
  self._collection.append(node)
388
403
 
404
+ # The tracker is shared by the buffers, so the row it saw text on may
405
+ # be another buffer's: only this buffer's own text can be continued
406
+ if self._position_tracer.consume_row_continuation() and self._line_has_text():
407
+ continued.continues_row = True
408
+
389
409
  padding = self._position_tracer.consume_pending_padding()
390
410
  if padding:
391
411
  node.add_chars(" " * padding)
392
412
  node.add_chars(*chars)
393
413
  self._position_tracer.advance_cursor(len("".join(chars)))
394
414
 
415
+ def _line_has_text(self):
416
+ """Whether the line being written in this buffer already holds text
417
+
418
+ :rtype: bool
419
+ """
420
+ for node in reversed(self._collection):
421
+ if _ends_line(node):
422
+ return False
423
+ if node.is_text_node() and node.text:
424
+ return True
425
+ return False
426
+
395
427
  def _settle_line_start(self):
396
428
  """Record on the break opening this line the column it now starts at
397
429
 
@@ -645,7 +677,11 @@ class InstructionNodeCreator:
645
677
  for idx, stash in enumerate(stash_list):
646
678
  # Resolved per stash: each is a row of its own, so an overwrite
647
679
  # is measured against its row rather than the rows joined together
648
- new_collection.extend(_resolve_overwrites(stash._collection))
680
+ new_collection.extend(
681
+ _clear_continuations_of_fitting_rows(
682
+ _resolve_overwrites(stash._collection)
683
+ )
684
+ )
649
685
 
650
686
  # use space to separate the stashes, but don't add final space
651
687
  if idx < len(stash_list) - 1:
@@ -878,6 +914,10 @@ class _InstructionNode:
878
914
  # only place a later line's own column survives
879
915
  self.line_start = line_start
880
916
  self.columns = columns
917
+ # For text, whether it continued a row already holding text, with no
918
+ # PAC or row change of its own; for an overwrite, whether the text
919
+ # after it did
920
+ self.continues_row = False
881
921
 
882
922
  def add_chars(self, *args):
883
923
  """This being a text node, add characters to it.
@@ -1099,7 +1139,10 @@ def _resolve_line_overwrites(line, line_start):
1099
1139
  """
1100
1140
  if not any(node.is_overwrite() for node in line):
1101
1141
  return line
1102
- line = [copy.copy(node) if node.is_text_node() else node for node in line]
1142
+ line = [
1143
+ copy.copy(node) if node.is_text_node() or node.is_overwrite() else node
1144
+ for node in line
1145
+ ]
1103
1146
  if line_start is None:
1104
1147
  line_start = next(
1105
1148
  (
@@ -1120,9 +1163,24 @@ def _resolve_line_overwrites(line, line_start):
1120
1163
  arriving += len(node.text)
1121
1164
  if line_start + _line_length(line) > 32 and arriving >= marker.columns:
1122
1165
  _drop_last_columns(line[:index], marker.columns)
1166
+ if not _line_length(line[:index]):
1167
+ # The row was sent again in full: nothing is left of it for
1168
+ # the text arriving to have continued
1169
+ _clear_continuations(line[: index + 1])
1123
1170
  return _join_across_markers(line)
1124
1171
 
1125
1172
 
1173
+ def _clear_continuations(nodes):
1174
+ """Clear the row continuation of the text and overwrite nodes, the line's
1175
+ own copies
1176
+
1177
+ :type nodes: list[_InstructionNode]
1178
+ """
1179
+ for node in nodes:
1180
+ if node.is_text_node() or node.is_overwrite():
1181
+ node.continues_row = False
1182
+
1183
+
1126
1184
  def _join_across_markers(line):
1127
1185
  """Drop the overwrite markers, joining the text nodes each one split
1128
1186
 
@@ -1138,7 +1196,11 @@ def _join_across_markers(line):
1138
1196
  for node in line:
1139
1197
  if node.is_overwrite():
1140
1198
  after_marker = True
1199
+ continued = node.continues_row
1141
1200
  continue
1201
+ if after_marker and node.is_text_node():
1202
+ # The text the marker opened, a copy, takes on its continuation
1203
+ node.continues_row |= continued
1142
1204
  if (
1143
1205
  after_marker
1144
1206
  and node.is_text_node()
@@ -1147,12 +1209,38 @@ def _join_across_markers(line):
1147
1209
  ):
1148
1210
  if node.text:
1149
1211
  joined[-1].add_chars(node.text)
1212
+ joined[-1].continues_row |= node.continues_row
1150
1213
  else:
1151
1214
  joined.append(node)
1152
1215
  after_marker = False
1153
1216
  return joined
1154
1217
 
1155
1218
 
1219
+ def _clear_continuations_of_fitting_rows(collection):
1220
+ """Clear the row continuation of every line that fits the row
1221
+
1222
+ Simulated roll-up joins its rows onto one line, which can run past column
1223
+ 32 although no row did. The continuation has to be judged on its own row,
1224
+ before the join, or it would take the blame for a length it did not cause.
1225
+
1226
+ The nodes are cleared in place, not copied: the row's nodes are shared with
1227
+ the stash it is re-read from, and a row that fits keeps fitting.
1228
+
1229
+ :type collection: list[_InstructionNode]
1230
+ :rtype: list[_InstructionNode]
1231
+ """
1232
+ line = []
1233
+ for node in collection + [None]:
1234
+ if node is not None and not _ends_line(node):
1235
+ line.append(node)
1236
+ continue
1237
+ if _line_length(line) <= 32:
1238
+ for line_node in line:
1239
+ line_node.continues_row = False
1240
+ line = []
1241
+ return collection
1242
+
1243
+
1156
1244
  def _drop_last_columns(line, columns):
1157
1245
  """Drop the last ``columns`` columns of text from the nodes of a line
1158
1246
 
@@ -43,6 +43,10 @@ class _PositioningTracker:
43
43
  self._pending_padding = 0
44
44
  # Columns a PAC pointed back over, not yet known to be an overwrite.
45
45
  self._pending_overlap = 0
46
+ # Text written next lands on a row that already holds text, reached
47
+ # with no PAC or row change of its own: a same-row PAC, or a later
48
+ # line of the file picking up where the row left off.
49
+ self._row_continued = False
46
50
 
47
51
  def update_positioning(
48
52
  self, positioning, column_jump_forces_reposition=False, is_offset=False
@@ -171,6 +175,7 @@ class _PositioningTracker:
171
175
  self._pending_overlap = 0
172
176
  self._pending_padding = 0
173
177
  self._line_has_text = False
178
+ self._row_continued = False
174
179
  # A skipped row, or a row+1 jump with a large paint-on column jump:
175
180
  # use repositioning (new cue)
176
181
  else:
@@ -218,6 +223,10 @@ class _PositioningTracker:
218
223
  self._last_column = new_col
219
224
  self._cursor_column = new_col
220
225
  else:
226
+ if not is_offset and self._line_has_text:
227
+ # A PAC on the row being written moves on along it instead of
228
+ # opening a row of its own
229
+ self._row_continued = True
221
230
  overlap = cursor - new_col
222
231
  if overlap < 0:
223
232
  # The columns between the text and the PAC are blank
@@ -258,6 +267,7 @@ class _PositioningTracker:
258
267
  # and its text is no longer in front of the cursor to be overwritten
259
268
  self._pending_padding = 0
260
269
  self._pending_overlap = 0
270
+ self._row_continued = False
261
271
 
262
272
  def advance_cursor(self, count):
263
273
  """Notify the tracker that ``count`` columns of text were written.
@@ -295,6 +305,22 @@ class _PositioningTracker:
295
305
  overlap, self._pending_overlap = self._pending_overlap, 0
296
306
  return overlap
297
307
 
308
+ def start_burst(self):
309
+ """Note that a new line of the file starts: text it writes before any
310
+ positioning command continues the row the previous line left off on.
311
+ """
312
+ if self._line_has_text:
313
+ self._row_continued = True
314
+
315
+ def consume_row_continuation(self):
316
+ """Return and clear whether the text arriving next continues a row
317
+ without a PAC or row change of its own.
318
+
319
+ :rtype: bool
320
+ """
321
+ continued, self._row_continued = self._row_continued, False
322
+ return continued
323
+
298
324
  def cancel_pending_overlap(self):
299
325
  """Drop the overlap, the column the PAC pointed at being taken by a
300
326
  command rather than by text.
@@ -361,6 +387,7 @@ class _PositioningTracker:
361
387
  self._line_has_text = False
362
388
  self._pending_padding = 0
363
389
  self._pending_overlap = 0
390
+ self._row_continued = False
364
391
  # Reset positions to None so the next PAC sets position fresh
365
392
  self._positions = [None]
366
393
 
@@ -405,9 +432,19 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
405
432
  :param positioning: a tuple of ints (row, col)
406
433
  :type positioning: tuple[int]
407
434
  """
435
+ # Text written before any PAC was placed on the default row
436
+ unplaced_row = (
437
+ self.default[0]
438
+ if self._line_has_text and not any(self._positions)
439
+ else None
440
+ )
408
441
  if positioning:
409
442
  self.default = positioning
410
443
 
411
444
  super().update_positioning(
412
445
  positioning, column_jump_forces_reposition, is_offset
413
446
  )
447
+ if not is_offset and positioning and positioning[0] == unplaced_row:
448
+ # The PAC names the row that text already took, so the text after
449
+ # it carries on along the row instead of opening one of its own
450
+ self._row_continued = True
@@ -0,0 +1,26 @@
1
+ """Shared utility functions for pycaption."""
2
+
3
+
4
+ def is_leaf(element):
5
+ """Return True if element is a BeautifulSoup leaf (NavigableString or <br>).
6
+
7
+ :param element: A BeautifulSoup Tag or NavigableString.
8
+ :rtype: bool
9
+ """
10
+ name = getattr(element, "name", None)
11
+ if not name or name == "br":
12
+ return True
13
+ return False
14
+
15
+
16
+ def encode_leading_nbsp(text):
17
+ """Write the leading non-breaking spaces of text as &#160; entities.
18
+
19
+ prettify() strips the ends of a paragraph's text, and Python counts U+00A0
20
+ as whitespace, so the indentation of a cue's first line would be lost.
21
+
22
+ :type text: str
23
+ :rtype: str
24
+ """
25
+ stripped = text.lstrip("\xa0")
26
+ return "&#160;" * (len(text) - len(stripped)) + stripped
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.12
3
+ Version: 2.3.13
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -21,7 +21,7 @@ transcript_dependencies = ["nltk==3.10.0"]
21
21
 
22
22
  setup(
23
23
  name="pycaption",
24
- version="2.3.12",
24
+ version="2.3.13",
25
25
  description="Closed caption converter",
26
26
  long_description=open(README_PATH).read(),
27
27
  author="Joe Norton",
@@ -43,6 +43,7 @@ from tests.fixtures.dfxp import ( # noqa: F401
43
43
  sample_dfxp_with_framerate_no_multiplier,
44
44
  sample_dfxp_with_inherited_style,
45
45
  sample_dfxp_with_inline_style,
46
+ sample_dfxp_with_nbsp_indentation,
46
47
  sample_dfxp_with_nested_spans,
47
48
  sample_dfxp_with_positioning,
48
49
  sample_dfxp_with_properly_closing_spans_output,
@@ -83,6 +84,8 @@ from tests.fixtures.sami import ( # noqa: F401
83
84
  sample_sami_with_multi_lang,
84
85
  sample_sami_with_multiple_p,
85
86
  sample_sami_with_multiple_span_aligns,
87
+ sample_sami_with_nbsp_indentation,
88
+ sample_sami_with_nbsp_only_line,
86
89
  sample_sami_with_p_align,
87
90
  sample_sami_with_p_and_span_align,
88
91
  sample_sami_with_separate_multi_lang,
@@ -94,6 +97,7 @@ from tests.fixtures.scc import ( # noqa: F401
94
97
  sample_no_positioning_at_all_scc,
95
98
  sample_scc_backspace_before_a_pac_ahead_of_the_cursor,
96
99
  sample_scc_blank_columns_abandoned_before_a_full_line,
100
+ sample_scc_caption_opening_with_a_blank_line_then_continuing_its_row,
97
101
  sample_scc_created_dfxp_with_wrongly_closing_spans,
98
102
  sample_scc_doubled_mid_row_before_punctuation,
99
103
  sample_scc_doubled_pac_with_tab_offset,
@@ -106,8 +110,12 @@ from tests.fixtures.scc import ( # noqa: F401
106
110
  sample_scc_flashing_cue,
107
111
  sample_scc_frame_29,
108
112
  sample_scc_frame_30,
113
+ sample_scc_indented_line_on_the_same_row_without_a_pac,
109
114
  sample_scc_italics_still_open_at_repositioning,
110
115
  sample_scc_line_break_then_repositioning,
116
+ sample_scc_long_line_after_a_repositioning,
117
+ sample_scc_long_line_after_a_row_change,
118
+ sample_scc_long_line_then_a_line_continuing_its_row,
111
119
  sample_scc_mid_row_before_text_paint,
112
120
  sample_scc_mid_row_before_text_pop,
113
121
  sample_scc_mid_row_before_text_roll,
@@ -126,6 +134,8 @@ from tests.fixtures.scc import ( # noqa: F401
126
134
  sample_scc_multiple_positioning,
127
135
  sample_scc_no_explicit_end_to_last_caption,
128
136
  sample_scc_pac_pointing_back_over_a_full_row_with_less_text,
137
+ sample_scc_pac_resending_a_continued_row_in_full_past_the_last_column,
138
+ sample_scc_pac_resending_a_row_in_full_then_a_line_continuing_it,
129
139
  sample_scc_pac_restating_the_position_after_text,
130
140
  sample_scc_pac_within_a_tab_offset_of_a_mid_row_code,
131
141
  sample_scc_pac_within_a_tab_offset_of_an_overwrite,
@@ -142,14 +152,19 @@ from tests.fixtures.scc import ( # noqa: F401
142
152
  sample_scc_pop_on_row_and_column_jump_in_one_pac,
143
153
  sample_scc_pop_on_row_plus_one_large_column_jump,
144
154
  sample_scc_pop_on_tab_offset_on_the_line_below_an_empty_row,
155
+ sample_scc_pop_on_text_then_paint_on_line_without_a_pac,
145
156
  sample_scc_produces_captions_with_start_and_end_time_the_same,
146
157
  sample_scc_restated_origin_pac_refilling_a_long_line,
158
+ sample_scc_roll_up_long_line_after_a_carriage_return,
159
+ sample_scc_roll_up_next_line_without_a_carriage_return,
147
160
  sample_scc_roll_up_pac_restating_the_row_mid_row,
148
161
  sample_scc_roll_up_pac_restating_the_row_mid_word,
149
162
  sample_scc_roll_up_restated_row_rolled_into_a_long_line,
150
163
  sample_scc_roll_up_row_and_column_jump_in_one_pac,
164
+ sample_scc_roll_up_row_continued_without_a_pac_then_rolled,
151
165
  sample_scc_roll_up_row_plus_one_large_column_jump,
152
166
  sample_scc_roll_up_row_refilled_then_rolled,
167
+ sample_scc_roll_up_row_sent_in_one_go_then_rolled,
153
168
  sample_scc_roll_up_ru2,
154
169
  sample_scc_roll_up_ru3,
155
170
  sample_scc_row_jump_with_pending_reposition,
@@ -165,6 +180,9 @@ from tests.fixtures.scc import ( # noqa: F401
165
180
  sample_scc_tab_offset_after_line_break,
166
181
  sample_scc_tab_offset_part_way_through_a_line,
167
182
  sample_scc_tab_offset_resolving_behind_the_cursor,
183
+ sample_scc_text_before_its_pac_on_another_row,
184
+ sample_scc_text_before_its_pac_on_the_same_row,
185
+ sample_scc_two_cues_the_second_continuing_its_row,
168
186
  sample_scc_with_ampersand_character,
169
187
  sample_scc_with_extended_characters,
170
188
  sample_scc_with_italics,
@@ -1620,3 +1620,21 @@ def sample_dfxp_with_custom_tickrate():
1620
1620
  </div>
1621
1621
  </body>
1622
1622
  </tt>"""
1623
+
1624
+
1625
+ @pytest.fixture(scope="session")
1626
+ def sample_dfxp_with_nbsp_indentation():
1627
+ return """\
1628
+ <?xml version="1.0" encoding="utf-8"?>
1629
+ <tt xml:lang="en" xmlns="http://www.w3.org/ns/ttml"
1630
+ xmlns:tts="http://www.w3.org/ns/ttml#styling">
1631
+ <body>
1632
+ <div xml:lang="en-US">
1633
+ <p begin="00:00:01.000" end="00:00:02.000">
1634
+ &#160;&#160;&#160;MOLLY:<br/>
1635
+ "The Story Knife."<br/>
1636
+ &#160;&#160;Part one
1637
+ </p>
1638
+ </div>
1639
+ </body>
1640
+ </tt>"""
@@ -722,3 +722,38 @@ def sample_sami_missing_start():
722
722
  </P></SYNC>
723
723
  </BODY></SAMI>
724
724
  """
725
+
726
+
727
+ @pytest.fixture(scope="session")
728
+ def sample_sami_with_nbsp_indentation():
729
+ return """
730
+ <SAMI><HEAD><STYLE TYPE="text/css">
731
+ <!--
732
+ .ENCC {Name: English; lang: en-US; SAMI_Type: CC;}
733
+ --></STYLE></HEAD><BODY>
734
+ <SYNC Start="1000"><P class="ENCC">
735
+ &#160;&#160;&#160;MOLLY:<br/>
736
+ "The Story Knife."<br/>
737
+ &nbsp;&nbsp;Part one
738
+ </P></SYNC>
739
+ <SYNC Start="2000"><P class="ENCC">&nbsp;</P></SYNC>
740
+ </BODY></SAMI>
741
+ """
742
+
743
+
744
+ @pytest.fixture(scope="session")
745
+ def sample_sami_with_nbsp_only_line():
746
+ return """
747
+ <SAMI><HEAD><STYLE TYPE="text/css">
748
+ <!--
749
+ .ENCC {Name: English; lang: en-US; SAMI_Type: CC;}
750
+ --></STYLE></HEAD><BODY>
751
+ <SYNC Start="1000"><P class="ENCC">
752
+ one<br/>
753
+ &nbsp;&nbsp;
754
+ <br/>
755
+ three
756
+ </P></SYNC>
757
+ <SYNC Start="2000"><P class="ENCC">&nbsp;</P></SYNC>
758
+ </BODY></SAMI>
759
+ """