pycaption 2.3.12__tar.gz → 2.3.14__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.14}/PKG-INFO +1 -1
  2. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/dfxp/extras.py +2 -1
  3. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/dfxp/reader.py +2 -1
  4. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/dfxp/writer.py +4 -1
  5. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/sami/reader.py +8 -2
  6. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/sami/writer.py +67 -60
  7. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/reader.py +29 -12
  8. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/specialized_collections.py +90 -2
  9. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/state_machines.py +37 -0
  10. pycaption-2.3.14/pycaption/utils.py +26 -0
  11. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/webvtt/reader.py +32 -24
  12. {pycaption-2.3.12 → pycaption-2.3.14/pycaption.egg-info}/PKG-INFO +1 -1
  13. {pycaption-2.3.12 → pycaption-2.3.14}/setup.py +1 -1
  14. {pycaption-2.3.12 → pycaption-2.3.14}/tests/conftest.py +30 -0
  15. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/dfxp.py +32 -0
  16. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/sami.py +35 -0
  17. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/scc.py +278 -0
  18. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/webvtt.py +144 -0
  19. {pycaption-2.3.12 → pycaption-2.3.14}/tests/mixins.py +19 -0
  20. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_dfxp.py +45 -0
  21. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_dfxp_extras.py +20 -0
  22. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_sami.py +185 -2
  23. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_sami_conversion.py +34 -0
  24. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_scc.py +178 -8
  25. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_webvtt.py +177 -0
  26. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_webvtt_conversion.py +54 -0
  27. pycaption-2.3.12/pycaption/utils.py +0 -13
  28. {pycaption-2.3.12 → pycaption-2.3.14}/LICENSE +0 -0
  29. {pycaption-2.3.12 → pycaption-2.3.14}/MANIFEST.in +0 -0
  30. {pycaption-2.3.12 → pycaption-2.3.14}/README.rst +0 -0
  31. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/__init__.py +0 -0
  32. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/base.py +0 -0
  33. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/dfxp/__init__.py +0 -0
  34. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/dfxp/constants.py +0 -0
  35. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/exceptions.py +0 -0
  36. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/geometry.py +0 -0
  37. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/microdvd.py +0 -0
  38. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/sami/__init__.py +0 -0
  39. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/sami/constants.py +0 -0
  40. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/sami/parser.py +0 -0
  41. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/__init__.py +0 -0
  42. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/constants.py +0 -0
  43. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/translator.py +0 -0
  44. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/scc/writer.py +0 -0
  45. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/srt.py +0 -0
  46. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/transcript.py +0 -0
  47. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/webvtt/__init__.py +0 -0
  48. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/webvtt/constants.py +0 -0
  49. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption/webvtt/writer.py +0 -0
  50. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption.egg-info/SOURCES.txt +0 -0
  51. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption.egg-info/dependency_links.txt +0 -0
  52. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption.egg-info/requires.txt +0 -0
  53. {pycaption-2.3.12 → pycaption-2.3.14}/pycaption.egg-info/top_level.txt +0 -0
  54. {pycaption-2.3.12 → pycaption-2.3.14}/setup.cfg +0 -0
  55. {pycaption-2.3.12 → pycaption-2.3.14}/tests/__init__.py +0 -0
  56. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/__init__.py +0 -0
  57. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/microdvd.py +0 -0
  58. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/srt.py +0 -0
  59. {pycaption-2.3.12 → pycaption-2.3.14}/tests/fixtures/translated_scc.py +0 -0
  60. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_base.py +0 -0
  61. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_bytes_input.py +0 -0
  62. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_dfxp_conversion.py +0 -0
  63. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_double_encoding.py +0 -0
  64. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_functions.py +0 -0
  65. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_geometry.py +0 -0
  66. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_microdvd.py +0 -0
  67. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_microdvd_conversion.py +0 -0
  68. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_scc_conversion.py +0 -0
  69. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_scc_translator.py +0 -0
  70. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_scc_writer.py +0 -0
  71. {pycaption-2.3.12 → pycaption-2.3.14}/tests/test_srt.py +0 -0
  72. {pycaption-2.3.12 → pycaption-2.3.14}/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.12
3
+ Version: 2.3.14
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
 
@@ -36,7 +37,6 @@ class SAMIWriter(BaseWriter):
36
37
  def __init__(self, *args, **kwargs):
37
38
  super().__init__(*args, **kwargs)
38
39
  self._span_stack = []
39
- self.last_time = None
40
40
  self._promote_center = False
41
41
 
42
42
  def write(self, caption_set, **kwargs):
@@ -50,13 +50,8 @@ class SAMIWriter(BaseWriter):
50
50
  caption_set.layout_info
51
51
  )
52
52
 
53
- primary = None
54
-
53
+ timelines = {}
55
54
  for lang in caption_set.get_languages():
56
- self.last_time = None
57
- if primary is None:
58
- primary = lang
59
-
60
55
  caption_set.set_layout_info(
61
56
  lang,
62
57
  self._relativize_and_fit_to_screen(caption_set.get_layout_info(lang)),
@@ -71,12 +66,11 @@ class SAMIWriter(BaseWriter):
71
66
  node.layout_info = self._relativize_and_fit_to_screen(
72
67
  node.layout_info
73
68
  )
74
- sami = self._recreate_p_tag(caption, sami, lang, primary, caption_set)
69
+ cues = self._build_timeline(captions)
70
+ if cues:
71
+ timelines[lang] = cues
75
72
 
76
- if self.last_time and captions:
77
- sami = self._recreate_blank_tag(
78
- sami, captions[-1], lang, primary, caption_set
79
- )
73
+ self._recreate_syncs(sami, timelines, caption_set)
80
74
 
81
75
  stylesheet = self._recreate_stylesheet(caption_set)
82
76
  sami.find("style").append(stylesheet)
@@ -85,17 +79,64 @@ class SAMIWriter(BaseWriter):
85
79
  caption_content = "\n".join(a[1:])
86
80
  return caption_content
87
81
 
88
- def _recreate_p_tag(self, caption, sami, lang, primary, captions):
89
- """Build a <sync><p> block for a single caption with styling and alignment."""
90
- time = int(caption.start // 1000)
91
-
92
- if self.last_time and time != self.last_time:
93
- sami = self._recreate_blank_tag(sami, caption, lang, primary, captions)
94
-
95
- self.last_time = int(caption.end // 1000)
82
+ @staticmethod
83
+ def _build_timeline(captions):
84
+ """Return (start_ms, end_ms, caption) cues sorted by start.
96
85
 
97
- sami, sync = self._recreate_sync(sami, lang, primary, time)
86
+ Zero-length cues are never on screen, so they are dropped.
87
+ """
88
+ cues = []
89
+ for caption in captions:
90
+ start = int(caption.start // 1000)
91
+ end = int(caption.end // 1000)
92
+ if end > start:
93
+ cues.append((start, end, caption))
94
+ cues.sort(key=lambda cue: cue[0])
95
+ return cues
96
+
97
+ def _recreate_syncs(self, sami, timelines, captions):
98
+ """Append one <sync> per time at which any language's visible cues change.
99
+
100
+ Overlapping cues of a language are merged: a <sync> repeats every cue
101
+ of that language still on screen, so start times never decrease and
102
+ no cue is cleared early.
103
+ """
104
+ times = sorted(
105
+ {time for cues in timelines.values() for cue in cues for time in cue[:2]}
106
+ )
107
+ shown = {lang: [] for lang in timelines}
108
+ pending = {lang: 0 for lang in timelines}
109
+
110
+ for time in times:
111
+ sync = None
112
+ for lang, cues in timelines.items():
113
+ active = [i for i in shown[lang] if cues[i][1] > time]
114
+ while pending[lang] < len(cues) and cues[pending[lang]][0] <= time:
115
+ active.append(pending[lang])
116
+ pending[lang] += 1
117
+ if active == shown[lang]:
118
+ continue
119
+ shown[lang] = active
120
+
121
+ if sync is None:
122
+ sync = sami.new_tag("sync", start=time)
123
+ sami.body.append(sync)
124
+
125
+ if active:
126
+ for i in active:
127
+ sync.append(
128
+ self._recreate_p_tag(cues[i][2], sami, lang, captions)
129
+ )
130
+ else:
131
+ # The blank takes the class of the next cue, or of the
132
+ # last cue once the language has no cues left.
133
+ upcoming = cues[min(pending[lang], len(cues) - 1)][2]
134
+ sync.append(
135
+ self._recreate_blank_tag(upcoming, sami, lang, captions)
136
+ )
98
137
 
138
+ def _recreate_p_tag(self, caption, sami, lang, captions):
139
+ """Build a <p> for a single caption with styling and alignment."""
99
140
  p = sami.new_tag("p")
100
141
 
101
142
  p_style = self._build_p_style(caption)
@@ -104,11 +145,9 @@ class SAMIWriter(BaseWriter):
104
145
  p["style"] = p_style
105
146
 
106
147
  p["class"] = self._recreate_p_lang(caption, lang, captions)
107
- p.string = self._recreate_text(caption.nodes)
148
+ p.string = encode_leading_nbsp(self._recreate_text(caption.nodes))
108
149
 
109
- sync.append(p)
110
-
111
- return sami
150
+ return p
112
151
 
113
152
  def _build_p_style(self, caption):
114
153
  """Build the inline CSS style string for a <p> element."""
@@ -157,45 +196,13 @@ class SAMIWriter(BaseWriter):
157
196
 
158
197
  return HORIZONTAL_ALIGNMENT_MAP.get(h)
159
198
 
160
- def _recreate_sync(self, sami, lang, primary, time):
161
- """Find or create a <sync> tag at the given millisecond timestamp."""
162
- if lang == primary:
163
- sync = sami.new_tag("sync", start=time)
164
- sami.body.append(sync)
165
- else:
166
- sync = sami.find("sync", start=time)
167
- if sync is None:
168
- sami, sync = self._find_closest_sync(sami, time)
169
-
170
- return sami, sync
171
-
172
- @staticmethod
173
- def _find_closest_sync(sami, time):
174
- """Insert a new <sync> tag in chronological order among existing syncs."""
175
- sync = sami.new_tag("sync", start=time)
176
-
177
- earlier = sami.find_all("sync", start=lambda x: int(x) < time)
178
- if earlier:
179
- last_sync = earlier[-1]
180
- last_sync.insert_after(sync)
181
- else:
182
- later = sami.find_all("sync", start=lambda x: int(x) > time)
183
- if later:
184
- last_sync = later[0]
185
- last_sync.insert_before(sync)
186
- return sami, sync
187
-
188
- def _recreate_blank_tag(self, sami, caption, lang, primary, captions):
189
- """Insert an &nbsp; paragraph to clear the previous caption at its end time."""
190
- sami, sync = self._recreate_sync(sami, lang, primary, self.last_time)
191
-
199
+ def _recreate_blank_tag(self, caption, sami, lang, captions):
200
+ """Build an &nbsp; <p> that clears the language's previous caption."""
192
201
  p = sami.new_tag("p")
193
202
  p["class"] = self._recreate_p_lang(caption, lang, captions)
194
203
  p.string = "&nbsp;"
195
204
 
196
- sync.append(p)
197
-
198
- return sami
205
+ return p
199
206
 
200
207
  @staticmethod
201
208
  def _recreate_p_lang(caption, lang, captions):
@@ -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