pycaption 2.3.9__tar.gz → 2.3.12__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.9/pycaption.egg-info → pycaption-2.3.12}/PKG-INFO +1 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/__init__.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/base.py +3 -2
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/constants.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/extras.py +2 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/reader.py +6 -6
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/writer.py +285 -18
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/geometry.py +3 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/microdvd.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/constants.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/reader.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/writer.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/__init__.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/constants.py +5 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/reader.py +21 -2
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/specialized_collections.py +429 -58
- pycaption-2.3.12/pycaption/scc/state_machines.py +413 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/writer.py +37 -13
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/srt.py +20 -4
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/transcript.py +7 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/__init__.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/constants.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/reader.py +1 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/webvtt/writer.py +264 -45
- {pycaption-2.3.9 → pycaption-2.3.12/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/SOURCES.txt +0 -1
- pycaption-2.3.12/setup.cfg +4 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/setup.py +3 -2
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/conftest.py +41 -6
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/scc.py +561 -3
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/webvtt.py +1 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/mixins.py +1 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_base.py +72 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_bytes_input.py +0 -2
- pycaption-2.3.12/tests/test_dfxp.py +868 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_dfxp_conversion.py +4 -3
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_double_encoding.py +1 -3
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_sami.py +2 -8
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_sami_conversion.py +4 -7
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc.py +718 -40
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc_conversion.py +89 -5
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc_translator.py +1 -3
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_scc_writer.py +107 -49
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_srt.py +66 -1
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_webvtt.py +12 -32
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_webvtt_conversion.py +590 -34
- pycaption-2.3.9/pycaption/scc/state_machines.py +0 -207
- pycaption-2.3.9/setup.cfg +0 -14
- pycaption-2.3.9/tests/test_dfxp.py +0 -441
- {pycaption-2.3.9 → pycaption-2.3.12}/LICENSE +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/MANIFEST.in +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/README.rst +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption/utils.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/__init__.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_functions.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_geometry.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.9 → pycaption-2.3.12}/tests/test_srt_conversion.py +0 -0
|
@@ -7,11 +7,13 @@ CaptionConverter orchestrator and base classes for readers/writers.
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
|
|
10
11
|
from datetime import timedelta
|
|
11
12
|
from numbers import Number
|
|
12
13
|
|
|
13
14
|
from .exceptions import CaptionReadError, CaptionReadTimingError, InvalidInputError
|
|
14
15
|
|
|
16
|
+
|
|
15
17
|
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
17
19
|
# `und` a special identifier for an undetermined language according to ISO 639-2
|
|
@@ -40,8 +42,7 @@ class CaptionConverter:
|
|
|
40
42
|
"""
|
|
41
43
|
if not hasattr(caption_reader, "read"):
|
|
42
44
|
raise InvalidInputError(
|
|
43
|
-
"The caption_reader must be a BaseReader instance "
|
|
44
|
-
"with a read() method."
|
|
45
|
+
"The caption_reader must be a BaseReader instance with a read() method."
|
|
45
46
|
)
|
|
46
47
|
self.captions = caption_reader.read(content)
|
|
47
48
|
return self
|
|
@@ -13,6 +13,7 @@ from ..base import BaseWriter, CaptionNode, merge_concurrent_captions
|
|
|
13
13
|
from .constants import DFXP_DEFAULT_REGION
|
|
14
14
|
from .writer import DFXPWriter
|
|
15
15
|
|
|
16
|
+
|
|
16
17
|
LEGACY_DFXP_BASE_MARKUP = """
|
|
17
18
|
<tt xmlns="http://www.w3.org/ns/ttml"
|
|
18
19
|
xmlns:tts="http://www.w3.org/ns/ttml#styling">
|
|
@@ -255,7 +256,7 @@ class LegacyDFXPWriter(BaseWriter):
|
|
|
255
256
|
|
|
256
257
|
return line
|
|
257
258
|
|
|
258
|
-
def _recreate_style(self, content, dfxp):
|
|
259
|
+
def _recreate_style(self, content, dfxp): # noqa: C901
|
|
259
260
|
"""Convert an internal style dict to DFXP/TTS attributes.
|
|
260
261
|
|
|
261
262
|
:type content: dict
|
|
@@ -47,6 +47,7 @@ from .constants import (
|
|
|
47
47
|
VERTICAL_ALIGNMENT_TO_DFXP,
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
+
|
|
50
51
|
_LEADING_WHITESPACE_RE = re.compile("^(?:[\n\r]+\\s*)?(.+)")
|
|
51
52
|
|
|
52
53
|
_DFXP_WRITING_MODE_MAP = {
|
|
@@ -147,7 +148,8 @@ class DFXPReader(BaseReader):
|
|
|
147
148
|
style_dict[id_] = self._convert_style(style)
|
|
148
149
|
|
|
149
150
|
caption_set = CaptionSet(
|
|
150
|
-
caption_dict,
|
|
151
|
+
caption_dict,
|
|
152
|
+
styles=style_dict,
|
|
151
153
|
visual_alignment_default=HorizontalAlignmentEnum.START,
|
|
152
154
|
)
|
|
153
155
|
|
|
@@ -179,8 +181,7 @@ class DFXPReader(BaseReader):
|
|
|
179
181
|
tickrate = float(tt_attrs["ttp:tickrate"])
|
|
180
182
|
except ValueError:
|
|
181
183
|
raise CaptionReadSyntaxError(
|
|
182
|
-
f"ttp:tickRate must be a number, "
|
|
183
|
-
f"got '{tt_attrs['ttp:tickrate']}'"
|
|
184
|
+
f"ttp:tickRate must be a number, got '{tt_attrs['ttp:tickrate']}'"
|
|
184
185
|
)
|
|
185
186
|
if tickrate <= 0:
|
|
186
187
|
raise CaptionReadSyntaxError(
|
|
@@ -203,8 +204,7 @@ class DFXPReader(BaseReader):
|
|
|
203
204
|
framerate_int = int(framerate_str)
|
|
204
205
|
except ValueError:
|
|
205
206
|
raise CaptionReadSyntaxError(
|
|
206
|
-
f"ttp:frameRate must be a positive integer, "
|
|
207
|
-
f"got '{framerate_str}'"
|
|
207
|
+
f"ttp:frameRate must be a positive integer, got '{framerate_str}'"
|
|
208
208
|
)
|
|
209
209
|
self.tickrate = float(framerate_int * sub_framerate)
|
|
210
210
|
|
|
@@ -396,7 +396,7 @@ class DFXPReader(BaseReader):
|
|
|
396
396
|
self._convert_tag_to_node(a)
|
|
397
397
|
|
|
398
398
|
@staticmethod
|
|
399
|
-
def _convert_style(tag):
|
|
399
|
+
def _convert_style(tag): # noqa: C901
|
|
400
400
|
"""Convert DFXP/TTS style attributes on a tag to an internal style dict.
|
|
401
401
|
|
|
402
402
|
Maps tts:fontStyle, tts:fontWeight, tts:textDecoration, tts:textAlign,
|
|
@@ -5,6 +5,7 @@ including style elements, region-based positioning, and writing direction.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import re
|
|
8
|
+
|
|
8
9
|
from copy import deepcopy
|
|
9
10
|
from xml.sax.saxutils import escape
|
|
10
11
|
|
|
@@ -26,6 +27,7 @@ from .constants import (
|
|
|
26
27
|
_create_external_alignment,
|
|
27
28
|
)
|
|
28
29
|
|
|
30
|
+
|
|
29
31
|
_WRITING_DIRECTION_TO_DFXP = {
|
|
30
32
|
WritingDirectionEnum.VERTICAL_RL: "tbrl",
|
|
31
33
|
WritingDirectionEnum.VERTICAL_LR: "tblr",
|
|
@@ -213,39 +215,278 @@ class DFXPWriter(BaseWriter):
|
|
|
213
215
|
|
|
214
216
|
return p
|
|
215
217
|
|
|
216
|
-
def _recreate_text(self, caption, dfxp, caption_set=None, lang=None):
|
|
218
|
+
def _recreate_text(self, caption, dfxp, caption_set=None, lang=None): # noqa: C901
|
|
217
219
|
"""Serialize all nodes of a caption into DFXP inline markup.
|
|
218
220
|
|
|
219
|
-
Handles text nodes, line breaks (<br/>), and style spans.
|
|
221
|
+
Handles text nodes, line breaks (<br/>), and style spans. Nodes are
|
|
222
|
+
first split into runs sharing one region; a run whose region is not
|
|
223
|
+
the one already in effect is wrapped in a <span> carrying it, so text
|
|
224
|
+
renders at its own position instead of inheriting the one on the
|
|
225
|
+
enclosing <p>, and a <br/> between two nodes of one region stays
|
|
226
|
+
inside that region. A run holding no visible text takes no wrapper:
|
|
227
|
+
there is nothing for the region to place.
|
|
220
228
|
|
|
221
229
|
:rtype: str
|
|
222
230
|
"""
|
|
223
231
|
line = ""
|
|
224
232
|
self._span_stack = []
|
|
233
|
+
paragraph = self.region_creator.get_positioning_info(lang, caption_set, caption)
|
|
234
|
+
paragraph_region, _ = paragraph
|
|
235
|
+
|
|
236
|
+
for region_id, region_attribs, nodes, wrappable in self._group_nodes_by_region(
|
|
237
|
+
caption, paragraph, caption_set, lang
|
|
238
|
+
):
|
|
239
|
+
wrap = (
|
|
240
|
+
wrappable
|
|
241
|
+
and region_id != self._innermost_region(paragraph_region)
|
|
242
|
+
and self._holds_visible_text(nodes)
|
|
243
|
+
)
|
|
244
|
+
if wrap:
|
|
245
|
+
attrs = {"region": region_id}
|
|
246
|
+
if self.write_inline_positioning:
|
|
247
|
+
attrs.update(region_attribs)
|
|
248
|
+
attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
|
249
|
+
line += f"<span {attr_str}>"
|
|
225
250
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
251
|
+
for node in nodes:
|
|
252
|
+
if node.type_ == CaptionNode.TEXT:
|
|
253
|
+
line += escape(node.content)
|
|
229
254
|
|
|
230
|
-
|
|
231
|
-
|
|
255
|
+
elif node.type_ == CaptionNode.BREAK:
|
|
256
|
+
line = line.rstrip() + "<br/>\n "
|
|
257
|
+
|
|
258
|
+
elif node.type_ == CaptionNode.STYLE:
|
|
259
|
+
line = self._recreate_span(
|
|
260
|
+
line, node, dfxp, caption_set, caption, lang
|
|
261
|
+
)
|
|
232
262
|
|
|
233
|
-
|
|
234
|
-
line =
|
|
263
|
+
if wrap:
|
|
264
|
+
line = line.rstrip() + "</span> "
|
|
235
265
|
|
|
236
266
|
while self._span_stack:
|
|
237
|
-
|
|
238
|
-
|
|
267
|
+
emitted, _ = self._span_stack.pop()
|
|
268
|
+
if emitted:
|
|
269
|
+
line = line.rstrip() + "</span> "
|
|
239
270
|
|
|
240
271
|
return line.rstrip()
|
|
241
272
|
|
|
273
|
+
def _group_nodes_by_region(self, caption, paragraph, caption_set, lang):
|
|
274
|
+
"""Split a caption's nodes into consecutive runs sharing one region.
|
|
275
|
+
|
|
276
|
+
Line breaks are neutral: a run absorbs one only when it continues
|
|
277
|
+
past it, so a break between two nodes of the same region ends up
|
|
278
|
+
inside that region's span rather than outside it. Breaks left over
|
|
279
|
+
between two runs form a run of their own, which is never wrapped.
|
|
280
|
+
A run is split again around any style tag whose partner falls
|
|
281
|
+
outside it, so one such tag costs only itself a wrapper.
|
|
282
|
+
|
|
283
|
+
:param paragraph: (region_id, positioning attributes) of the enclosing <p>
|
|
284
|
+
:return: (region_id, positioning_attributes, nodes, wrappable) per run
|
|
285
|
+
:rtype: list[tuple[str | None, dict, list[CaptionNode], bool]]
|
|
286
|
+
"""
|
|
287
|
+
resolved, partners = self._resolve_node_regions(
|
|
288
|
+
caption, paragraph, caption_set, lang
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
groups = []
|
|
292
|
+
current = None
|
|
293
|
+
pending = []
|
|
294
|
+
|
|
295
|
+
for index, (region_id, region_attribs) in enumerate(resolved):
|
|
296
|
+
if region_id is None:
|
|
297
|
+
pending.append(index)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
if current and region_id == current[0]:
|
|
301
|
+
current[2].extend(pending)
|
|
302
|
+
else:
|
|
303
|
+
if current:
|
|
304
|
+
groups.append(current)
|
|
305
|
+
if pending:
|
|
306
|
+
groups.append((None, {}, pending))
|
|
307
|
+
current = [region_id, region_attribs, []]
|
|
308
|
+
pending = []
|
|
309
|
+
current[2].append(index)
|
|
310
|
+
|
|
311
|
+
if current:
|
|
312
|
+
groups.append(current)
|
|
313
|
+
if pending:
|
|
314
|
+
groups.append((None, {}, pending))
|
|
315
|
+
|
|
316
|
+
return [
|
|
317
|
+
(
|
|
318
|
+
region_id,
|
|
319
|
+
region_attribs,
|
|
320
|
+
[caption.nodes[index] for index in segment],
|
|
321
|
+
region_id is not None
|
|
322
|
+
and self._is_wrappable(segment, partners, caption.nodes),
|
|
323
|
+
)
|
|
324
|
+
for region_id, region_attribs, indices in groups
|
|
325
|
+
for segment in self._split_at_crossing_styles(
|
|
326
|
+
indices, partners, caption.nodes
|
|
327
|
+
)
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _holds_visible_text(nodes):
|
|
332
|
+
"""Check whether a run has any text for a region to place.
|
|
333
|
+
|
|
334
|
+
A run of nothing but style tags and breaks renders nothing, so a
|
|
335
|
+
wrapper carrying its region would be an empty span.
|
|
336
|
+
|
|
337
|
+
:rtype: bool
|
|
338
|
+
"""
|
|
339
|
+
return any(
|
|
340
|
+
node.type_ == CaptionNode.TEXT and node.content.strip() for node in nodes
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _crosses_run(index, partners, nodes, run):
|
|
345
|
+
"""Check whether the node at an index leaves the run it belongs to.
|
|
346
|
+
|
|
347
|
+
A style tag whose partner sits outside the run does: a span wrapping
|
|
348
|
+
it would cross that tag's own span and produce invalid XML. A
|
|
349
|
+
closing tag with no partner at all is the exception — nothing was
|
|
350
|
+
opened for it, so it writes no tag and crosses nothing.
|
|
351
|
+
|
|
352
|
+
:param run: the indices making up the run
|
|
353
|
+
:type run: set[int]
|
|
354
|
+
:rtype: bool
|
|
355
|
+
"""
|
|
356
|
+
node = nodes[index]
|
|
357
|
+
if node.type_ != CaptionNode.STYLE:
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
if not node.start and index not in partners:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
return partners.get(index) not in run
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def _split_at_crossing_styles(cls, indices, partners, nodes):
|
|
367
|
+
"""Split a run around style tags whose partner sits outside it.
|
|
368
|
+
|
|
369
|
+
A span wrapping such a tag would cross another span and produce
|
|
370
|
+
invalid XML, but only that tag is in the way — the nodes beside it
|
|
371
|
+
still deserve their own region, so they come back as runs of their
|
|
372
|
+
own instead of losing their position along with it.
|
|
373
|
+
|
|
374
|
+
:rtype: list[list[int]]
|
|
375
|
+
"""
|
|
376
|
+
run = set(indices)
|
|
377
|
+
segments = [[]]
|
|
378
|
+
|
|
379
|
+
for index in indices:
|
|
380
|
+
if cls._crosses_run(index, partners, nodes, run):
|
|
381
|
+
segments += [[index], []]
|
|
382
|
+
else:
|
|
383
|
+
segments[-1].append(index)
|
|
384
|
+
|
|
385
|
+
return [segment for segment in segments if segment]
|
|
386
|
+
|
|
387
|
+
def _resolve_node_regions(self, caption, paragraph, caption_set, lang):
|
|
388
|
+
"""Resolve the region every node of a caption belongs to.
|
|
389
|
+
|
|
390
|
+
A node's own layout wins; lacking one it takes the region in effect
|
|
391
|
+
around it — the innermost enclosing style span's, else the
|
|
392
|
+
paragraph's. For a closing style node that is its own opener's
|
|
393
|
+
region, so a span is never separated from its own end tag. Line
|
|
394
|
+
breaks belong to no region and resolve to None, as does a closing
|
|
395
|
+
tag with nothing open — it writes no tag at all. A region travels
|
|
396
|
+
with the attributes that describe it, so a span reinstating one can
|
|
397
|
+
also write it inline.
|
|
398
|
+
|
|
399
|
+
:param paragraph: (region_id, positioning attributes) of the <p>
|
|
400
|
+
:return: a (region_id, positioning_attributes) pair per node, plus a
|
|
401
|
+
map pairing the index of each style tag with its partner's
|
|
402
|
+
:rtype: tuple[list[tuple[str | None, dict]], dict]
|
|
403
|
+
"""
|
|
404
|
+
creator = self.region_creator
|
|
405
|
+
resolved = []
|
|
406
|
+
partners = {}
|
|
407
|
+
open_styles = []
|
|
408
|
+
|
|
409
|
+
for index, node in enumerate(caption.nodes):
|
|
410
|
+
in_effect = open_styles[-1][1] if open_styles else paragraph
|
|
411
|
+
|
|
412
|
+
if node.type_ == CaptionNode.BREAK:
|
|
413
|
+
resolved.append((None, {}))
|
|
414
|
+
|
|
415
|
+
elif node.type_ == CaptionNode.TEXT:
|
|
416
|
+
own = (None, {})
|
|
417
|
+
if node.content.strip():
|
|
418
|
+
own = creator.get_node_positioning_info(node.layout_info)
|
|
419
|
+
resolved.append(own if own[0] else in_effect)
|
|
420
|
+
|
|
421
|
+
elif node.start:
|
|
422
|
+
if node.layout_info:
|
|
423
|
+
region = creator.get_positioning_info(
|
|
424
|
+
lang, caption_set, caption, node
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
region = in_effect
|
|
428
|
+
open_styles.append((index, region))
|
|
429
|
+
resolved.append(region)
|
|
430
|
+
|
|
431
|
+
elif open_styles:
|
|
432
|
+
opener, _ = open_styles.pop()
|
|
433
|
+
partners[opener] = index
|
|
434
|
+
partners[index] = opener
|
|
435
|
+
resolved.append(in_effect)
|
|
436
|
+
|
|
437
|
+
else:
|
|
438
|
+
resolved.append((None, {}))
|
|
439
|
+
|
|
440
|
+
return resolved, partners
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def _is_wrappable(cls, indices, partners, nodes):
|
|
444
|
+
"""Check whether a run of nodes can take a <span> holding its region.
|
|
445
|
+
|
|
446
|
+
No style tag in the run may leave it, or the wrapping span would
|
|
447
|
+
cross a style span and produce invalid XML — which is what rules out
|
|
448
|
+
a tag split off on its own. A run that is nothing but one style span
|
|
449
|
+
already writes any region of its own on that span, so it needs no
|
|
450
|
+
wrapper.
|
|
451
|
+
|
|
452
|
+
:rtype: bool
|
|
453
|
+
"""
|
|
454
|
+
run = set(indices)
|
|
455
|
+
for index in indices:
|
|
456
|
+
if cls._crosses_run(index, partners, nodes, run):
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
first = nodes[indices[0]]
|
|
460
|
+
|
|
461
|
+
return not (
|
|
462
|
+
first.type_ == CaptionNode.STYLE
|
|
463
|
+
and first.start
|
|
464
|
+
and partners.get(indices[0]) == indices[-1]
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def _innermost_region(self, default):
|
|
468
|
+
"""Return the region in effect at the current point in the caption.
|
|
469
|
+
|
|
470
|
+
The innermost open span carrying a region wins; with no such span,
|
|
471
|
+
the region on the enclosing <p> applies.
|
|
472
|
+
|
|
473
|
+
:param default: region of the enclosing <p>
|
|
474
|
+
:rtype: str
|
|
475
|
+
"""
|
|
476
|
+
for _, region_id in reversed(self._span_stack):
|
|
477
|
+
if region_id:
|
|
478
|
+
return region_id
|
|
479
|
+
|
|
480
|
+
return default
|
|
481
|
+
|
|
242
482
|
def _recreate_span(
|
|
243
483
|
self, line, node, dfxp, caption_set=None, caption=None, lang=None
|
|
244
484
|
):
|
|
245
485
|
"""Open or close a <span> element for a style node.
|
|
246
486
|
|
|
247
487
|
Supports nested spans via a stack. Opening pushes onto the stack;
|
|
248
|
-
closing pops the most recent span.
|
|
488
|
+
closing pops the most recent span. Each entry records whether a tag
|
|
489
|
+
was actually emitted and which region it carries, if any.
|
|
249
490
|
|
|
250
491
|
:param line: the accumulated markup string so far
|
|
251
492
|
:type line: str
|
|
@@ -254,6 +495,7 @@ class DFXPWriter(BaseWriter):
|
|
|
254
495
|
"""
|
|
255
496
|
if node.start:
|
|
256
497
|
attrs = _recreate_style(node.content, dfxp)
|
|
498
|
+
region_id = None
|
|
257
499
|
if node.layout_info:
|
|
258
500
|
region_id, region_attribs = self.region_creator.get_positioning_info(
|
|
259
501
|
lang, caption_set, caption, node
|
|
@@ -265,13 +507,13 @@ class DFXPWriter(BaseWriter):
|
|
|
265
507
|
if attrs:
|
|
266
508
|
attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
|
267
509
|
line += f"<span {attr_str}>"
|
|
268
|
-
self._span_stack.append(True)
|
|
510
|
+
self._span_stack.append((True, region_id))
|
|
269
511
|
else:
|
|
270
|
-
self._span_stack.append(False)
|
|
512
|
+
self._span_stack.append((False, None))
|
|
271
513
|
|
|
272
514
|
else:
|
|
273
515
|
if self._span_stack:
|
|
274
|
-
had_span = self._span_stack.pop()
|
|
516
|
+
had_span, _ = self._span_stack.pop()
|
|
275
517
|
if had_span:
|
|
276
518
|
line = line.rstrip() + "</span> "
|
|
277
519
|
|
|
@@ -331,7 +573,12 @@ class RegionCreator:
|
|
|
331
573
|
"""Create <region> tags in the <layout> section for each Layout.
|
|
332
574
|
|
|
333
575
|
Skips Layout objects that have no positioning data (no origin,
|
|
334
|
-
extent, padding, alignment, or writing_direction).
|
|
576
|
+
extent, padding, alignment, or writing_direction). A line alignment
|
|
577
|
+
on its own is not positioning data: it is what a WebVTT ``line``
|
|
578
|
+
setting leaves behind when its value yields no line to place the cue
|
|
579
|
+
at — ``line:auto,start``, or a value the grammar discards outright —
|
|
580
|
+
so the cue keeps the default placement rather than taking a vertical
|
|
581
|
+
one from the qualifier alone.
|
|
335
582
|
|
|
336
583
|
:param unique_layouts: iterable of geometry.Layout instances
|
|
337
584
|
:type dfxp: BeautifulSoup
|
|
@@ -450,6 +697,26 @@ class RegionCreator:
|
|
|
450
697
|
|
|
451
698
|
return region_id, positioning_attributes
|
|
452
699
|
|
|
700
|
+
def get_node_positioning_info(self, layout_info):
|
|
701
|
+
"""Return (region_id, positioning_attributes) for a node's own layout.
|
|
702
|
+
|
|
703
|
+
Unlike get_positioning_info, this neither cascades to the caption and
|
|
704
|
+
the caption set nor falls back to the default region: a layout without
|
|
705
|
+
a region of its own yields (None, {}).
|
|
706
|
+
|
|
707
|
+
:type layout_info: geometry.Layout | None
|
|
708
|
+
:rtype: tuple[str | None, dict]
|
|
709
|
+
"""
|
|
710
|
+
region_id = self._region_map.get(layout_info)
|
|
711
|
+
if not region_id:
|
|
712
|
+
return None, {}
|
|
713
|
+
|
|
714
|
+
self._assigned_region_ids.add(region_id)
|
|
715
|
+
|
|
716
|
+
return region_id, _convert_layout_to_attributes(
|
|
717
|
+
layout_info, self._fallback_alignment
|
|
718
|
+
)
|
|
719
|
+
|
|
453
720
|
def cleanup_regions(self):
|
|
454
721
|
"""Remove <region> tags that were never assigned to any element."""
|
|
455
722
|
layout_tag = self._dfxp.find("layout")
|
|
@@ -491,7 +758,7 @@ def _text_shadow_to_outline(value):
|
|
|
491
758
|
return thickness
|
|
492
759
|
|
|
493
760
|
|
|
494
|
-
def _recreate_style(content, dfxp):
|
|
761
|
+
def _recreate_style(content, dfxp): # noqa: C901
|
|
495
762
|
"""Convert an internal style dict to DFXP/TTS style attributes.
|
|
496
763
|
|
|
497
764
|
Maps pycaption's internal keys (class, italics, bold, underline, color,
|
|
@@ -542,7 +809,7 @@ def _recreate_style(content, dfxp):
|
|
|
542
809
|
return dfxp_style
|
|
543
810
|
|
|
544
811
|
|
|
545
|
-
def _convert_layout_to_attributes(layout, fallback_alignment=None):
|
|
812
|
+
def _convert_layout_to_attributes(layout, fallback_alignment=None): # noqa: C901
|
|
546
813
|
"""Convert a Layout object to a dict of DFXP region attributes.
|
|
547
814
|
|
|
548
815
|
Maps origin, extent, padding, alignment, and writing_direction to their
|
|
@@ -7,12 +7,15 @@ CONVENTIONS:
|
|
|
7
7
|
responsible for the recalculation should return a new object with the
|
|
8
8
|
necessary modifications.
|
|
9
9
|
"""
|
|
10
|
+
|
|
10
11
|
import re
|
|
12
|
+
|
|
11
13
|
from enum import Enum
|
|
12
14
|
from functools import total_ordering
|
|
13
15
|
|
|
14
16
|
from .exceptions import CaptionReadSyntaxError, RelativizationError
|
|
15
17
|
|
|
18
|
+
|
|
16
19
|
_UNIT_MISMATCH_MSG = "The sizes should have the same measure units."
|
|
17
20
|
|
|
18
21
|
|
|
@@ -11,6 +11,7 @@ from ..exceptions import CaptionReadNoCaptions, CaptionReadTimingError
|
|
|
11
11
|
from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, Padding, Size
|
|
12
12
|
from .parser import SAMIParser
|
|
13
13
|
|
|
14
|
+
|
|
14
15
|
_TAG_TO_STYLE = {"i": "italics", "b": "bold", "u": "underline"}
|
|
15
16
|
|
|
16
17
|
|
|
@@ -7,8 +7,10 @@ background color codes, and derived inverse lookup tables.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import re as _re
|
|
10
|
+
|
|
10
11
|
from itertools import product
|
|
11
12
|
|
|
13
|
+
|
|
12
14
|
COMMANDS = {
|
|
13
15
|
"9420": "",
|
|
14
16
|
"9429": "",
|
|
@@ -1317,6 +1319,9 @@ SPECIAL_OR_EXTENDED_CHAR_TO_CODE = {
|
|
|
1317
1319
|
SPECIAL_OR_EXTENDED_CHAR_TO_CODE.update(
|
|
1318
1320
|
{character: code for code, character in SPECIAL_CHARS.items()}
|
|
1319
1321
|
)
|
|
1322
|
+
# A non-breaking space is what CEA-608's transparent space is: a blank column
|
|
1323
|
+
# that is not a break opportunity. Unmapped, it fell back to the pound sign.
|
|
1324
|
+
SPECIAL_OR_EXTENDED_CHAR_TO_CODE["\xa0"] = "91b9"
|
|
1320
1325
|
|
|
1321
1326
|
# Time to transmit a single codeword = 1 second / 29.97
|
|
1322
1327
|
MICROSECONDS_PER_CODEWORD = 1000.0 * 1000.0 / (30.0 * 1000.0 / 1001.0)
|
|
@@ -78,6 +78,7 @@ http://www.theneitherworld.com/mcpoodle/SCC_TOOLS/DOCS/SCC_FORMAT.HTML
|
|
|
78
78
|
"""
|
|
79
79
|
|
|
80
80
|
import re
|
|
81
|
+
|
|
81
82
|
from collections import deque
|
|
82
83
|
from copy import deepcopy
|
|
83
84
|
|
|
@@ -108,6 +109,7 @@ from .specialized_collections import (
|
|
|
108
109
|
)
|
|
109
110
|
from .state_machines import DefaultProvidingPositionTracker
|
|
110
111
|
|
|
112
|
+
|
|
111
113
|
_TIMECODE_RE = re.compile(r"\d{2}:\d{2}:\d{2}[:;](\d{1,2})")
|
|
112
114
|
|
|
113
115
|
|
|
@@ -242,7 +244,7 @@ class SCCReader(BaseReader):
|
|
|
242
244
|
def _validate_line_lengths(self):
|
|
243
245
|
"""Raise CaptionLineLengthError if any line exceeds 32 characters."""
|
|
244
246
|
violations = [
|
|
245
|
-
f"around {cap.format_start()} - {
|
|
247
|
+
f"around {cap.format_start()} - {self._describe_line_length(line)}"
|
|
246
248
|
for cap in (c.to_real_caption() for c in self.caption_stash._collection)
|
|
247
249
|
for line in cap.get_text().split("\n")
|
|
248
250
|
if len(line) > 32
|
|
@@ -253,6 +255,19 @@ class SCCReader(BaseReader):
|
|
|
253
255
|
"Lines longer than 32:\n" + "\n".join(violations)
|
|
254
256
|
)
|
|
255
257
|
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _describe_line_length(line):
|
|
260
|
+
"""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.
|
|
262
|
+
"""
|
|
263
|
+
text = line.lstrip("\xa0")
|
|
264
|
+
indent = len(line) - len(text)
|
|
265
|
+
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
|
+
)
|
|
270
|
+
|
|
256
271
|
@staticmethod
|
|
257
272
|
def _fix_last_captions_without_ending(caption_list):
|
|
258
273
|
"""Set end = start + 4s for trailing captions that were never ended."""
|
|
@@ -364,7 +379,11 @@ class SCCReader(BaseReader):
|
|
|
364
379
|
if self._is_doubled_type(word) and word == self.last_command:
|
|
365
380
|
if word in CUE_STARTING_COMMAND:
|
|
366
381
|
self.double_starter = True
|
|
367
|
-
|
|
382
|
+
# A doubled PAC stays on record so that a Tab Offset following it is
|
|
383
|
+
# still recognised as belonging to a PAC and applied; clearing it
|
|
384
|
+
# would leave the offset looking like a stray duplicate and drop the
|
|
385
|
+
# indentation it carries.
|
|
386
|
+
self.last_command = word if self._is_pac_command(word) else ""
|
|
368
387
|
return True
|
|
369
388
|
|
|
370
389
|
if self._is_pac_command(word) and word in self.last_command:
|