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.
- {pycaption-2.3.12/pycaption.egg-info → pycaption-2.3.13}/PKG-INFO +1 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/extras.py +2 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/reader.py +2 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/writer.py +4 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/reader.py +8 -2
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/writer.py +2 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/reader.py +29 -12
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/specialized_collections.py +90 -2
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/state_machines.py +37 -0
- pycaption-2.3.13/pycaption/utils.py +26 -0
- {pycaption-2.3.12 → pycaption-2.3.13/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/setup.py +1 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/conftest.py +18 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/dfxp.py +18 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/sami.py +35 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/scc.py +278 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_dfxp.py +45 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_dfxp_extras.py +20 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_sami.py +63 -1
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc.py +178 -8
- pycaption-2.3.12/pycaption/utils.py +0 -13
- {pycaption-2.3.12 → pycaption-2.3.13}/LICENSE +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/MANIFEST.in +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/README.rst +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/base.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/geometry.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/srt.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/transcript.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/constants.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/reader.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption/webvtt/writer.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/setup.cfg +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/mixins.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_base.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_functions.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_geometry.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_srt.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_webvtt.py +0 -0
- {pycaption-2.3.12 → pycaption-2.3.13}/tests/test_webvtt_conversion.py +0 -0
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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(
|
|
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 = [
|
|
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   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 " " * (len(text) - len(stripped)) + stripped
|
|
@@ -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
|
+
   MOLLY:<br/>
|
|
1635
|
+
"The Story Knife."<br/>
|
|
1636
|
+
  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
|
+
   MOLLY:<br/>
|
|
736
|
+
"The Story Knife."<br/>
|
|
737
|
+
Part one
|
|
738
|
+
</P></SYNC>
|
|
739
|
+
<SYNC Start="2000"><P class="ENCC"> </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
|
+
|
|
754
|
+
<br/>
|
|
755
|
+
three
|
|
756
|
+
</P></SYNC>
|
|
757
|
+
<SYNC Start="2000"><P class="ENCC"> </P></SYNC>
|
|
758
|
+
</BODY></SAMI>
|
|
759
|
+
"""
|