pycaption 2.3.8__tar.gz → 2.3.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycaption-2.3.8/pycaption.egg-info → pycaption-2.3.10}/PKG-INFO +1 -1
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/writer.py +280 -15
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/parser.py +4 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/reader.py +7 -1
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/reader.py +16 -6
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/specialized_collections.py +31 -19
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/state_machines.py +48 -19
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/srt.py +20 -10
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/writer.py +262 -45
- {pycaption-2.3.8 → pycaption-2.3.10/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.8 → pycaption-2.3.10}/setup.py +1 -1
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/conftest.py +9 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/scc.py +134 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_base.py +82 -9
- pycaption-2.3.10/tests/test_dfxp.py +867 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_sami.py +20 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_scc.py +228 -3
- pycaption-2.3.10/tests/test_scc_conversion.py +297 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_srt.py +19 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_webvtt_conversion.py +596 -0
- pycaption-2.3.8/tests/test_dfxp.py +0 -441
- pycaption-2.3.8/tests/test_scc_conversion.py +0 -117
- {pycaption-2.3.8 → pycaption-2.3.10}/LICENSE +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/MANIFEST.in +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/README.rst +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/base.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/geometry.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/sami/writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/transcript.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/utils.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/constants.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption/webvtt/reader.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/setup.cfg +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/mixins.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_functions.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_geometry.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.3.8 → pycaption-2.3.10}/tests/test_webvtt.py +0 -0
|
@@ -216,36 +216,275 @@ class DFXPWriter(BaseWriter):
|
|
|
216
216
|
def _recreate_text(self, caption, dfxp, caption_set=None, lang=None):
|
|
217
217
|
"""Serialize all nodes of a caption into DFXP inline markup.
|
|
218
218
|
|
|
219
|
-
Handles text nodes, line breaks (<br/>), and style spans.
|
|
219
|
+
Handles text nodes, line breaks (<br/>), and style spans. Nodes are
|
|
220
|
+
first split into runs sharing one region; a run whose region is not
|
|
221
|
+
the one already in effect is wrapped in a <span> carrying it, so text
|
|
222
|
+
renders at its own position instead of inheriting the one on the
|
|
223
|
+
enclosing <p>, and a <br/> between two nodes of one region stays
|
|
224
|
+
inside that region. A run holding no visible text takes no wrapper:
|
|
225
|
+
there is nothing for the region to place.
|
|
220
226
|
|
|
221
227
|
:rtype: str
|
|
222
228
|
"""
|
|
223
229
|
line = ""
|
|
224
230
|
self._span_stack = []
|
|
231
|
+
paragraph = self.region_creator.get_positioning_info(lang, caption_set, caption)
|
|
232
|
+
paragraph_region, _ = paragraph
|
|
233
|
+
|
|
234
|
+
for region_id, region_attribs, nodes, wrappable in self._group_nodes_by_region(
|
|
235
|
+
caption, paragraph, caption_set, lang
|
|
236
|
+
):
|
|
237
|
+
wrap = (
|
|
238
|
+
wrappable
|
|
239
|
+
and region_id != self._innermost_region(paragraph_region)
|
|
240
|
+
and self._holds_visible_text(nodes)
|
|
241
|
+
)
|
|
242
|
+
if wrap:
|
|
243
|
+
attrs = {"region": region_id}
|
|
244
|
+
if self.write_inline_positioning:
|
|
245
|
+
attrs.update(region_attribs)
|
|
246
|
+
attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
|
247
|
+
line += f"<span {attr_str}>"
|
|
225
248
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
249
|
+
for node in nodes:
|
|
250
|
+
if node.type_ == CaptionNode.TEXT:
|
|
251
|
+
line += escape(node.content)
|
|
229
252
|
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
elif node.type_ == CaptionNode.BREAK:
|
|
254
|
+
line = line.rstrip() + "<br/>\n "
|
|
232
255
|
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
elif node.type_ == CaptionNode.STYLE:
|
|
257
|
+
line = self._recreate_span(
|
|
258
|
+
line, node, dfxp, caption_set, caption, lang
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if wrap:
|
|
262
|
+
line = line.rstrip() + "</span> "
|
|
235
263
|
|
|
236
264
|
while self._span_stack:
|
|
237
|
-
|
|
238
|
-
|
|
265
|
+
emitted, _ = self._span_stack.pop()
|
|
266
|
+
if emitted:
|
|
267
|
+
line = line.rstrip() + "</span> "
|
|
239
268
|
|
|
240
269
|
return line.rstrip()
|
|
241
270
|
|
|
271
|
+
def _group_nodes_by_region(self, caption, paragraph, caption_set, lang):
|
|
272
|
+
"""Split a caption's nodes into consecutive runs sharing one region.
|
|
273
|
+
|
|
274
|
+
Line breaks are neutral: a run absorbs one only when it continues
|
|
275
|
+
past it, so a break between two nodes of the same region ends up
|
|
276
|
+
inside that region's span rather than outside it. Breaks left over
|
|
277
|
+
between two runs form a run of their own, which is never wrapped.
|
|
278
|
+
A run is split again around any style tag whose partner falls
|
|
279
|
+
outside it, so one such tag costs only itself a wrapper.
|
|
280
|
+
|
|
281
|
+
:param paragraph: (region_id, positioning attributes) of the enclosing <p>
|
|
282
|
+
:return: (region_id, positioning_attributes, nodes, wrappable) per run
|
|
283
|
+
:rtype: list[tuple[str | None, dict, list[CaptionNode], bool]]
|
|
284
|
+
"""
|
|
285
|
+
resolved, partners = self._resolve_node_regions(
|
|
286
|
+
caption, paragraph, caption_set, lang
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
groups = []
|
|
290
|
+
current = None
|
|
291
|
+
pending = []
|
|
292
|
+
|
|
293
|
+
for index, (region_id, region_attribs) in enumerate(resolved):
|
|
294
|
+
if region_id is None:
|
|
295
|
+
pending.append(index)
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
if current and region_id == current[0]:
|
|
299
|
+
current[2].extend(pending)
|
|
300
|
+
else:
|
|
301
|
+
if current:
|
|
302
|
+
groups.append(current)
|
|
303
|
+
if pending:
|
|
304
|
+
groups.append((None, {}, pending))
|
|
305
|
+
current = [region_id, region_attribs, []]
|
|
306
|
+
pending = []
|
|
307
|
+
current[2].append(index)
|
|
308
|
+
|
|
309
|
+
if current:
|
|
310
|
+
groups.append(current)
|
|
311
|
+
if pending:
|
|
312
|
+
groups.append((None, {}, pending))
|
|
313
|
+
|
|
314
|
+
return [
|
|
315
|
+
(
|
|
316
|
+
region_id,
|
|
317
|
+
region_attribs,
|
|
318
|
+
[caption.nodes[index] for index in segment],
|
|
319
|
+
region_id is not None
|
|
320
|
+
and self._is_wrappable(segment, partners, caption.nodes),
|
|
321
|
+
)
|
|
322
|
+
for region_id, region_attribs, indices in groups
|
|
323
|
+
for segment in self._split_at_crossing_styles(
|
|
324
|
+
indices, partners, caption.nodes
|
|
325
|
+
)
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _holds_visible_text(nodes):
|
|
330
|
+
"""Check whether a run has any text for a region to place.
|
|
331
|
+
|
|
332
|
+
A run of nothing but style tags and breaks renders nothing, so a
|
|
333
|
+
wrapper carrying its region would be an empty span.
|
|
334
|
+
|
|
335
|
+
:rtype: bool
|
|
336
|
+
"""
|
|
337
|
+
return any(
|
|
338
|
+
node.type_ == CaptionNode.TEXT and node.content.strip() for node in nodes
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _crosses_run(index, partners, nodes, run):
|
|
343
|
+
"""Check whether the node at an index leaves the run it belongs to.
|
|
344
|
+
|
|
345
|
+
A style tag whose partner sits outside the run does: a span wrapping
|
|
346
|
+
it would cross that tag's own span and produce invalid XML. A
|
|
347
|
+
closing tag with no partner at all is the exception — nothing was
|
|
348
|
+
opened for it, so it writes no tag and crosses nothing.
|
|
349
|
+
|
|
350
|
+
:param run: the indices making up the run
|
|
351
|
+
:type run: set[int]
|
|
352
|
+
:rtype: bool
|
|
353
|
+
"""
|
|
354
|
+
node = nodes[index]
|
|
355
|
+
if node.type_ != CaptionNode.STYLE:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
if not node.start and index not in partners:
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
return partners.get(index) not in run
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def _split_at_crossing_styles(cls, indices, partners, nodes):
|
|
365
|
+
"""Split a run around style tags whose partner sits outside it.
|
|
366
|
+
|
|
367
|
+
A span wrapping such a tag would cross another span and produce
|
|
368
|
+
invalid XML, but only that tag is in the way — the nodes beside it
|
|
369
|
+
still deserve their own region, so they come back as runs of their
|
|
370
|
+
own instead of losing their position along with it.
|
|
371
|
+
|
|
372
|
+
:rtype: list[list[int]]
|
|
373
|
+
"""
|
|
374
|
+
run = set(indices)
|
|
375
|
+
segments = [[]]
|
|
376
|
+
|
|
377
|
+
for index in indices:
|
|
378
|
+
if cls._crosses_run(index, partners, nodes, run):
|
|
379
|
+
segments += [[index], []]
|
|
380
|
+
else:
|
|
381
|
+
segments[-1].append(index)
|
|
382
|
+
|
|
383
|
+
return [segment for segment in segments if segment]
|
|
384
|
+
|
|
385
|
+
def _resolve_node_regions(self, caption, paragraph, caption_set, lang):
|
|
386
|
+
"""Resolve the region every node of a caption belongs to.
|
|
387
|
+
|
|
388
|
+
A node's own layout wins; lacking one it takes the region in effect
|
|
389
|
+
around it — the innermost enclosing style span's, else the
|
|
390
|
+
paragraph's. For a closing style node that is its own opener's
|
|
391
|
+
region, so a span is never separated from its own end tag. Line
|
|
392
|
+
breaks belong to no region and resolve to None, as does a closing
|
|
393
|
+
tag with nothing open — it writes no tag at all. A region travels
|
|
394
|
+
with the attributes that describe it, so a span reinstating one can
|
|
395
|
+
also write it inline.
|
|
396
|
+
|
|
397
|
+
:param paragraph: (region_id, positioning attributes) of the <p>
|
|
398
|
+
:return: a (region_id, positioning_attributes) pair per node, plus a
|
|
399
|
+
map pairing the index of each style tag with its partner's
|
|
400
|
+
:rtype: tuple[list[tuple[str | None, dict]], dict]
|
|
401
|
+
"""
|
|
402
|
+
creator = self.region_creator
|
|
403
|
+
resolved = []
|
|
404
|
+
partners = {}
|
|
405
|
+
open_styles = []
|
|
406
|
+
|
|
407
|
+
for index, node in enumerate(caption.nodes):
|
|
408
|
+
in_effect = open_styles[-1][1] if open_styles else paragraph
|
|
409
|
+
|
|
410
|
+
if node.type_ == CaptionNode.BREAK:
|
|
411
|
+
resolved.append((None, {}))
|
|
412
|
+
|
|
413
|
+
elif node.type_ == CaptionNode.TEXT:
|
|
414
|
+
own = (None, {})
|
|
415
|
+
if node.content.strip():
|
|
416
|
+
own = creator.get_node_positioning_info(node.layout_info)
|
|
417
|
+
resolved.append(own if own[0] else in_effect)
|
|
418
|
+
|
|
419
|
+
elif node.start:
|
|
420
|
+
if node.layout_info:
|
|
421
|
+
region = creator.get_positioning_info(
|
|
422
|
+
lang, caption_set, caption, node
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
region = in_effect
|
|
426
|
+
open_styles.append((index, region))
|
|
427
|
+
resolved.append(region)
|
|
428
|
+
|
|
429
|
+
elif open_styles:
|
|
430
|
+
opener, _ = open_styles.pop()
|
|
431
|
+
partners[opener] = index
|
|
432
|
+
partners[index] = opener
|
|
433
|
+
resolved.append(in_effect)
|
|
434
|
+
|
|
435
|
+
else:
|
|
436
|
+
resolved.append((None, {}))
|
|
437
|
+
|
|
438
|
+
return resolved, partners
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def _is_wrappable(cls, indices, partners, nodes):
|
|
442
|
+
"""Check whether a run of nodes can take a <span> holding its region.
|
|
443
|
+
|
|
444
|
+
No style tag in the run may leave it, or the wrapping span would
|
|
445
|
+
cross a style span and produce invalid XML — which is what rules out
|
|
446
|
+
a tag split off on its own. A run that is nothing but one style span
|
|
447
|
+
already writes any region of its own on that span, so it needs no
|
|
448
|
+
wrapper.
|
|
449
|
+
|
|
450
|
+
:rtype: bool
|
|
451
|
+
"""
|
|
452
|
+
run = set(indices)
|
|
453
|
+
for index in indices:
|
|
454
|
+
if cls._crosses_run(index, partners, nodes, run):
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
first = nodes[indices[0]]
|
|
458
|
+
|
|
459
|
+
return not (
|
|
460
|
+
first.type_ == CaptionNode.STYLE
|
|
461
|
+
and first.start
|
|
462
|
+
and partners.get(indices[0]) == indices[-1]
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def _innermost_region(self, default):
|
|
466
|
+
"""Return the region in effect at the current point in the caption.
|
|
467
|
+
|
|
468
|
+
The innermost open span carrying a region wins; with no such span,
|
|
469
|
+
the region on the enclosing <p> applies.
|
|
470
|
+
|
|
471
|
+
:param default: region of the enclosing <p>
|
|
472
|
+
:rtype: str
|
|
473
|
+
"""
|
|
474
|
+
for _, region_id in reversed(self._span_stack):
|
|
475
|
+
if region_id:
|
|
476
|
+
return region_id
|
|
477
|
+
|
|
478
|
+
return default
|
|
479
|
+
|
|
242
480
|
def _recreate_span(
|
|
243
481
|
self, line, node, dfxp, caption_set=None, caption=None, lang=None
|
|
244
482
|
):
|
|
245
483
|
"""Open or close a <span> element for a style node.
|
|
246
484
|
|
|
247
485
|
Supports nested spans via a stack. Opening pushes onto the stack;
|
|
248
|
-
closing pops the most recent span.
|
|
486
|
+
closing pops the most recent span. Each entry records whether a tag
|
|
487
|
+
was actually emitted and which region it carries, if any.
|
|
249
488
|
|
|
250
489
|
:param line: the accumulated markup string so far
|
|
251
490
|
:type line: str
|
|
@@ -254,6 +493,7 @@ class DFXPWriter(BaseWriter):
|
|
|
254
493
|
"""
|
|
255
494
|
if node.start:
|
|
256
495
|
attrs = _recreate_style(node.content, dfxp)
|
|
496
|
+
region_id = None
|
|
257
497
|
if node.layout_info:
|
|
258
498
|
region_id, region_attribs = self.region_creator.get_positioning_info(
|
|
259
499
|
lang, caption_set, caption, node
|
|
@@ -265,13 +505,13 @@ class DFXPWriter(BaseWriter):
|
|
|
265
505
|
if attrs:
|
|
266
506
|
attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
|
267
507
|
line += f"<span {attr_str}>"
|
|
268
|
-
self._span_stack.append(True)
|
|
508
|
+
self._span_stack.append((True, region_id))
|
|
269
509
|
else:
|
|
270
|
-
self._span_stack.append(False)
|
|
510
|
+
self._span_stack.append((False, None))
|
|
271
511
|
|
|
272
512
|
else:
|
|
273
513
|
if self._span_stack:
|
|
274
|
-
had_span = self._span_stack.pop()
|
|
514
|
+
had_span, _ = self._span_stack.pop()
|
|
275
515
|
if had_span:
|
|
276
516
|
line = line.rstrip() + "</span> "
|
|
277
517
|
|
|
@@ -331,7 +571,12 @@ class RegionCreator:
|
|
|
331
571
|
"""Create <region> tags in the <layout> section for each Layout.
|
|
332
572
|
|
|
333
573
|
Skips Layout objects that have no positioning data (no origin,
|
|
334
|
-
extent, padding, alignment, or writing_direction).
|
|
574
|
+
extent, padding, alignment, or writing_direction). A line alignment
|
|
575
|
+
on its own is not positioning data: it is what a WebVTT ``line``
|
|
576
|
+
setting leaves behind when its value yields no line to place the cue
|
|
577
|
+
at — ``line:auto,start``, or a value the grammar discards outright —
|
|
578
|
+
so the cue keeps the default placement rather than taking a vertical
|
|
579
|
+
one from the qualifier alone.
|
|
335
580
|
|
|
336
581
|
:param unique_layouts: iterable of geometry.Layout instances
|
|
337
582
|
:type dfxp: BeautifulSoup
|
|
@@ -450,6 +695,26 @@ class RegionCreator:
|
|
|
450
695
|
|
|
451
696
|
return region_id, positioning_attributes
|
|
452
697
|
|
|
698
|
+
def get_node_positioning_info(self, layout_info):
|
|
699
|
+
"""Return (region_id, positioning_attributes) for a node's own layout.
|
|
700
|
+
|
|
701
|
+
Unlike get_positioning_info, this neither cascades to the caption and
|
|
702
|
+
the caption set nor falls back to the default region: a layout without
|
|
703
|
+
a region of its own yields (None, {}).
|
|
704
|
+
|
|
705
|
+
:type layout_info: geometry.Layout | None
|
|
706
|
+
:rtype: tuple[str | None, dict]
|
|
707
|
+
"""
|
|
708
|
+
region_id = self._region_map.get(layout_info)
|
|
709
|
+
if not region_id:
|
|
710
|
+
return None, {}
|
|
711
|
+
|
|
712
|
+
self._assigned_region_ids.add(region_id)
|
|
713
|
+
|
|
714
|
+
return region_id, _convert_layout_to_attributes(
|
|
715
|
+
layout_info, self._fallback_alignment
|
|
716
|
+
)
|
|
717
|
+
|
|
453
718
|
def cleanup_regions(self):
|
|
454
719
|
"""Remove <region> tags that were never assigned to any element."""
|
|
455
720
|
layout_tag = self._dfxp.find("layout")
|
|
@@ -174,6 +174,10 @@ class SAMIParser(HTMLParser):
|
|
|
174
174
|
:rtype: str | None
|
|
175
175
|
"""
|
|
176
176
|
for attr, value in attrs:
|
|
177
|
+
if value is None:
|
|
178
|
+
# A valueless attribute (e.g. a bare ``class``) carries no
|
|
179
|
+
# language information; skip it rather than crashing.
|
|
180
|
+
continue
|
|
177
181
|
if attr.lower() == "lang":
|
|
178
182
|
return value[:2]
|
|
179
183
|
if attr.lower() == "class":
|
|
@@ -142,7 +142,13 @@ class SAMIReader(BaseReader):
|
|
|
142
142
|
raise CaptionReadTimingError(
|
|
143
143
|
f"Missing start time on the following line: {p.parent}."
|
|
144
144
|
)
|
|
145
|
-
|
|
145
|
+
try:
|
|
146
|
+
milliseconds = int(float(start_str))
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise CaptionReadTimingError(
|
|
149
|
+
f"Invalid start time {start_str!r} on the following "
|
|
150
|
+
f"line: {p.parent}."
|
|
151
|
+
)
|
|
146
152
|
start = milliseconds * 1000
|
|
147
153
|
|
|
148
154
|
self._backfill_end_times(captions, start)
|
|
@@ -100,6 +100,7 @@ from .constants import (
|
|
|
100
100
|
SPECIAL_CHARS,
|
|
101
101
|
)
|
|
102
102
|
from .specialized_collections import (
|
|
103
|
+
PUNCTUATION_PREFIXES,
|
|
103
104
|
CaptionCreator,
|
|
104
105
|
InstructionNodeCreator,
|
|
105
106
|
NotifyingDict,
|
|
@@ -302,23 +303,28 @@ class SCCReader(BaseReader):
|
|
|
302
303
|
word = word.strip()
|
|
303
304
|
if len(word) == 4:
|
|
304
305
|
# Look ahead for the next command, skipping the duplicate
|
|
305
|
-
# that SCC uses for error-correction (same word repeated)
|
|
306
|
+
# that SCC uses for error-correction (same word repeated), to
|
|
307
|
+
# tell whether it decodes to punctuation.
|
|
306
308
|
next_idx = idx + 1
|
|
307
309
|
if next_idx < len(word_list) and word_list[next_idx].strip() == word:
|
|
308
310
|
next_idx += 1
|
|
309
311
|
next_command = (
|
|
310
312
|
word_list[next_idx] if next_idx < len(word_list) else None
|
|
311
313
|
)
|
|
312
|
-
|
|
314
|
+
next_is_punctuation = (
|
|
315
|
+
next_command is not None
|
|
316
|
+
and next_command[:2] in PUNCTUATION_PREFIXES
|
|
317
|
+
)
|
|
318
|
+
self._translate_word(word=word, next_is_punctuation=next_is_punctuation)
|
|
313
319
|
|
|
314
|
-
def _translate_word(self, word,
|
|
320
|
+
def _translate_word(self, word, next_is_punctuation=False):
|
|
315
321
|
"""Dispatch a single 4-char hex word as command, special char, or text."""
|
|
316
322
|
if self._handle_double_command(word):
|
|
317
323
|
# count frames for timing
|
|
318
324
|
self.time_translator._frames += 1
|
|
319
325
|
return
|
|
320
326
|
if word in COMMANDS or self._is_pac_command(word):
|
|
321
|
-
self._translate_command(word=word,
|
|
327
|
+
self._translate_command(word=word, next_is_punctuation=next_is_punctuation)
|
|
322
328
|
|
|
323
329
|
# second, check if word is a special character
|
|
324
330
|
elif word in SPECIAL_CHARS:
|
|
@@ -392,7 +398,7 @@ class SCCReader(BaseReader):
|
|
|
392
398
|
|
|
393
399
|
_ROLL_UP_DEPTH = {"9425": 2, "9426": 3, "94a7": 4}
|
|
394
400
|
|
|
395
|
-
def _translate_command(self, word,
|
|
401
|
+
def _translate_command(self, word, next_is_punctuation=False):
|
|
396
402
|
"""Route a CEA-608 control command to the appropriate handler."""
|
|
397
403
|
if word == "9420":
|
|
398
404
|
self._cmd_pop_on()
|
|
@@ -409,7 +415,11 @@ class SCCReader(BaseReader):
|
|
|
409
415
|
elif word == "942c":
|
|
410
416
|
self._cmd_erase_displayed()
|
|
411
417
|
else:
|
|
412
|
-
self.buffer.interpret_command(
|
|
418
|
+
self.buffer.interpret_command(
|
|
419
|
+
command=word,
|
|
420
|
+
next_is_punctuation=next_is_punctuation,
|
|
421
|
+
is_paint_on=self.buffer_dict.active_key == "paint",
|
|
422
|
+
)
|
|
413
423
|
|
|
414
424
|
def _cmd_pop_on(self):
|
|
415
425
|
"""Handle Resume Caption Loading [RCL] - switch to pop-on mode."""
|
|
@@ -32,7 +32,7 @@ from .constants import (
|
|
|
32
32
|
PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
|
|
33
33
|
|
|
34
34
|
# First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
|
|
35
|
-
|
|
35
|
+
PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class PreCaption:
|
|
@@ -412,7 +412,7 @@ class InstructionNodeCreator:
|
|
|
412
412
|
# only remaining possibility is plain text
|
|
413
413
|
return "plaintext"
|
|
414
414
|
|
|
415
|
-
def interpret_command(self, command,
|
|
415
|
+
def interpret_command(self, command, next_is_punctuation=False, is_paint_on=False):
|
|
416
416
|
"""Given a command determines whether to turn italics on or off,
|
|
417
417
|
or to set the positioning
|
|
418
418
|
|
|
@@ -420,9 +420,15 @@ class InstructionNodeCreator:
|
|
|
420
420
|
|
|
421
421
|
:type command: str
|
|
422
422
|
or a PAC_TAB_OFFSET_COMMANDS
|
|
423
|
-
:type
|
|
423
|
+
:type next_is_punctuation: bool
|
|
424
|
+
:param next_is_punctuation: whether the command right after this one
|
|
425
|
+
decodes to punctuation (comma, period, etc.)
|
|
426
|
+
:type is_paint_on: bool
|
|
427
|
+
:param is_paint_on: whether this command is being interpreted while
|
|
428
|
+
in paint-on mode, where a row+1 jump paired with a large column
|
|
429
|
+
jump indicates an unrelated, independently-positioned region
|
|
424
430
|
"""
|
|
425
|
-
self._update_positioning(command)
|
|
431
|
+
self._update_positioning(command, is_paint_on)
|
|
426
432
|
|
|
427
433
|
if command == "94a1":
|
|
428
434
|
self.handle_backspace("94a1")
|
|
@@ -434,7 +440,7 @@ class InstructionNodeCreator:
|
|
|
434
440
|
self._handle_style_command(command)
|
|
435
441
|
|
|
436
442
|
if command in MID_ROW_CODES and command not in PAC_TAB_OFFSET_COMMANDS:
|
|
437
|
-
self._handle_mid_row_spacing(
|
|
443
|
+
self._handle_mid_row_spacing(next_is_punctuation)
|
|
438
444
|
|
|
439
445
|
def _handle_background_color(self):
|
|
440
446
|
"""Strip trailing space before a background color code (CEA-608 rule)."""
|
|
@@ -460,9 +466,7 @@ class InstructionNodeCreator:
|
|
|
460
466
|
if self.last_style is not None and self.last_style != "italics off":
|
|
461
467
|
return
|
|
462
468
|
self._emit_pending_breaks(position)
|
|
463
|
-
self._collection.append(
|
|
464
|
-
_InstructionNode.create_italics_style(position)
|
|
465
|
-
)
|
|
469
|
+
self._collection.append(_InstructionNode.create_italics_style(position))
|
|
466
470
|
self.last_style = "italics on"
|
|
467
471
|
|
|
468
472
|
def _close_italics(self, position):
|
|
@@ -482,24 +486,29 @@ class InstructionNodeCreator:
|
|
|
482
486
|
if not self._position_tracer.is_linebreak_required():
|
|
483
487
|
return
|
|
484
488
|
for _ in range(self._position_tracer._breaks_required):
|
|
485
|
-
self._collection.append(
|
|
486
|
-
_InstructionNode.create_break(position=position)
|
|
487
|
-
)
|
|
489
|
+
self._collection.append(_InstructionNode.create_break(position=position))
|
|
488
490
|
self._position_tracer.acknowledge_linebreak_consumed()
|
|
489
491
|
|
|
490
|
-
def _handle_mid_row_spacing(self,
|
|
492
|
+
def _handle_mid_row_spacing(self, next_is_punctuation):
|
|
491
493
|
"""Insert spacing around mid-row code style transitions."""
|
|
492
|
-
|
|
494
|
+
if self._position_tracer.is_repositioning_required():
|
|
495
|
+
# A repositioning is already pending with no text written at the
|
|
496
|
+
# current position — that position is about to be abandoned, so
|
|
497
|
+
# padding it with a decorative space would wrongly consume the
|
|
498
|
+
# pending repositioning before the real content arrives.
|
|
499
|
+
return
|
|
493
500
|
prev_text_node = self.get_previous_text_node()
|
|
494
501
|
if not prev_text_node:
|
|
495
502
|
return
|
|
496
503
|
prev_node_is_break = any(
|
|
497
504
|
x.is_explicit_break()
|
|
498
|
-
for x in self._collection[self._collection.index(prev_text_node):]
|
|
505
|
+
for x in self._collection[self._collection.index(prev_text_node) :]
|
|
499
506
|
)
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
if (
|
|
508
|
+
prev_node_is_break
|
|
509
|
+
or prev_text_node.text[-1].isspace()
|
|
510
|
+
or next_is_punctuation
|
|
511
|
+
):
|
|
503
512
|
return
|
|
504
513
|
|
|
505
514
|
if self.last_style == "italics off":
|
|
@@ -507,10 +516,11 @@ class InstructionNodeCreator:
|
|
|
507
516
|
else:
|
|
508
517
|
prev_text_node.text = prev_text_node.text + " "
|
|
509
518
|
|
|
510
|
-
def _update_positioning(self, command):
|
|
519
|
+
def _update_positioning(self, command, is_paint_on=False):
|
|
511
520
|
"""Sets the positioning information to use for the next nodes
|
|
512
521
|
|
|
513
522
|
:type command: str
|
|
523
|
+
:type is_paint_on: bool
|
|
514
524
|
"""
|
|
515
525
|
is_offset = False
|
|
516
526
|
if command in PAC_TAB_OFFSET_COMMANDS:
|
|
@@ -529,7 +539,9 @@ class InstructionNodeCreator:
|
|
|
529
539
|
offset_after_break = is_offset and self.has_break_before(self._collection)
|
|
530
540
|
if not offset_after_break:
|
|
531
541
|
# Tab offsets after line breaks will be ignored to avoid repositioning
|
|
532
|
-
self._position_tracer.update_positioning(
|
|
542
|
+
self._position_tracer.update_positioning(
|
|
543
|
+
positioning, column_jump_forces_reposition=is_paint_on
|
|
544
|
+
)
|
|
533
545
|
|
|
534
546
|
def __iter__(self):
|
|
535
547
|
return iter(_format_italics(self._collection))
|
|
@@ -25,17 +25,39 @@ class _PositioningTracker:
|
|
|
25
25
|
# next positioning is actually a Tab Offset
|
|
26
26
|
self._last_column = None
|
|
27
27
|
|
|
28
|
-
def update_positioning(self, positioning):
|
|
28
|
+
def update_positioning(self, positioning, column_jump_forces_reposition=False):
|
|
29
29
|
"""Being notified of a position change, updates the internal state,
|
|
30
30
|
to as to be able to tell if it was a trivial change (a simple line
|
|
31
31
|
break) or not.
|
|
32
32
|
|
|
33
33
|
Strategy:
|
|
34
|
-
-
|
|
35
|
-
|
|
34
|
+
- A jump to the very next row (row + 1): Use a single line break —
|
|
35
|
+
this is simple text wrapping onto the next line. Exception: if
|
|
36
|
+
paired with a column jump bigger than a tab offset in paint-on
|
|
37
|
+
mode (see ``column_jump_forces_reposition``), it's treated as a
|
|
38
|
+
repositioning instead, since paint-on is the only mode that can
|
|
39
|
+
display independent, simultaneously-timed regions one row apart.
|
|
40
|
+
- Any other row jump (a skipped row, or a jump backwards): Use
|
|
41
|
+
repositioning (creates new cue). A skipped row is not an
|
|
42
|
+
intentional blank line to preserve — CEA-608 PACs only declare an
|
|
43
|
+
absolute row position; nothing in the spec says a gap between two
|
|
44
|
+
rows must be rendered as a blank line downstream, and WebVTT/HTML
|
|
45
|
+
renderers draw a real, often background-filled line for each
|
|
46
|
+
break, which previously produced a visible black bar between two
|
|
47
|
+
lines of text that were never meant to have a gap between them.
|
|
36
48
|
|
|
37
49
|
:type positioning: tuple[int]
|
|
38
50
|
:param positioning: a tuple (row, col)
|
|
51
|
+
|
|
52
|
+
:type column_jump_forces_reposition: bool
|
|
53
|
+
:param column_jump_forces_reposition: when True, a row+1 jump
|
|
54
|
+
paired with a column jump larger than a tab offset (>3 columns)
|
|
55
|
+
is treated as a repositioning rather than a break. Only
|
|
56
|
+
paint-on mode can display independent, simultaneously-timed
|
|
57
|
+
regions at unrelated columns, so callers should only set this
|
|
58
|
+
for paint-on buffers — pop-on and roll-up buffers legitimately
|
|
59
|
+
use large column shifts between buffered/wrapped lines of the
|
|
60
|
+
same cue.
|
|
39
61
|
"""
|
|
40
62
|
current = self._positions[-1]
|
|
41
63
|
|
|
@@ -51,24 +73,31 @@ class _PositioningTracker:
|
|
|
51
73
|
new_row, new_col = positioning
|
|
52
74
|
is_tab_offset = new_row == row and col + 1 <= new_col <= col + 3
|
|
53
75
|
|
|
54
|
-
# Threshold for when to use breaks vs repositioning
|
|
55
|
-
# Jumps of 4+ rows will trigger repositioning instead of adding breaks
|
|
56
|
-
max_breaks_threshold = 3
|
|
57
|
-
|
|
58
76
|
# Handle row jumps
|
|
59
77
|
if new_row > row:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
is_large_column_jump = (
|
|
79
|
+
column_jump_forces_reposition and abs(new_col - col) > 3
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# A jump to the very next row: use a line break. But if a
|
|
83
|
+
# repositioning was already pending and unconsumed (no text was
|
|
84
|
+
# ever written at the current position), this row jump is
|
|
85
|
+
# continuing that same unresolved position change, not wrapping
|
|
86
|
+
# text — so it must stay a repositioning rather than become a
|
|
87
|
+
# break. Likewise, in paint-on mode, pairing the row+1 jump
|
|
88
|
+
# with a column jump bigger than a tab offset means the new PAC
|
|
89
|
+
# almost certainly targets an unrelated, independently-positioned
|
|
90
|
+
# region rather than wrapping the current text.
|
|
91
|
+
if (
|
|
92
|
+
new_row == row + 1
|
|
93
|
+
and not self._repositioning_required
|
|
94
|
+
and not is_large_column_jump
|
|
95
|
+
):
|
|
64
96
|
self._positions.append((new_row, col))
|
|
65
|
-
|
|
66
|
-
# Row N -> N+1: 1 break
|
|
67
|
-
# Row N -> N+2: 2 breaks (preserves 1 blank line)
|
|
68
|
-
# Row N -> N+3: 3 breaks (preserves 2 blank lines)
|
|
69
|
-
self._breaks_required = row_diff
|
|
97
|
+
self._breaks_required = 1
|
|
70
98
|
self._last_column = new_col
|
|
71
|
-
#
|
|
99
|
+
# A skipped row, a jump backwards, or a row+1 jump with a large
|
|
100
|
+
# paint-on column jump: use repositioning (new cue)
|
|
72
101
|
else:
|
|
73
102
|
# Reset position - this triggers repositioning
|
|
74
103
|
self._positions = [positioning]
|
|
@@ -165,7 +194,7 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
|
|
|
165
194
|
except CaptionReadSyntaxError:
|
|
166
195
|
return self.default
|
|
167
196
|
|
|
168
|
-
def update_positioning(self, positioning):
|
|
197
|
+
def update_positioning(self, positioning, column_jump_forces_reposition=False):
|
|
169
198
|
"""If called, sets this positioning as the default, then delegates
|
|
170
199
|
to the super class.
|
|
171
200
|
|
|
@@ -175,4 +204,4 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
|
|
|
175
204
|
if positioning:
|
|
176
205
|
self.default = positioning
|
|
177
206
|
|
|
178
|
-
super().update_positioning(positioning)
|
|
207
|
+
super().update_positioning(positioning, column_jump_forces_reposition)
|