pycaption 2.2.26__tar.gz → 2.2.27__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {pycaption-2.2.26/pycaption.egg-info → pycaption-2.2.27}/PKG-INFO +1 -1
  2. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/webvtt.py +257 -24
  3. {pycaption-2.2.26 → pycaption-2.2.27/pycaption.egg-info}/PKG-INFO +1 -1
  4. {pycaption-2.2.26 → pycaption-2.2.27}/setup.py +1 -1
  5. {pycaption-2.2.26 → pycaption-2.2.27}/tests/conftest.py +2 -0
  6. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/webvtt.py +35 -0
  7. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_webvtt.py +339 -3
  8. pycaption-2.2.27/tests/test_webvtt_conversion.py +252 -0
  9. pycaption-2.2.26/tests/test_webvtt_conversion.py +0 -116
  10. {pycaption-2.2.26 → pycaption-2.2.27}/LICENSE +0 -0
  11. {pycaption-2.2.26 → pycaption-2.2.27}/MANIFEST.in +0 -0
  12. {pycaption-2.2.26 → pycaption-2.2.27}/README.rst +0 -0
  13. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/__init__.py +0 -0
  14. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/base.py +0 -0
  15. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/dfxp/__init__.py +0 -0
  16. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/dfxp/base.py +0 -0
  17. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/dfxp/extras.py +0 -0
  18. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/exceptions.py +0 -0
  19. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/geometry.py +0 -0
  20. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/microdvd.py +0 -0
  21. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/sami.py +0 -0
  22. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/__init__.py +0 -0
  23. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/constants.py +0 -0
  24. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/specialized_collections.py +0 -0
  25. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/state_machines.py +0 -0
  26. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/scc/translator.py +0 -0
  27. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/srt.py +0 -0
  28. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/transcript.py +0 -0
  29. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption/utils.py +0 -0
  30. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/SOURCES.txt +0 -0
  31. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/dependency_links.txt +0 -0
  32. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/requires.txt +0 -0
  33. {pycaption-2.2.26 → pycaption-2.2.27}/pycaption.egg-info/top_level.txt +0 -0
  34. {pycaption-2.2.26 → pycaption-2.2.27}/setup.cfg +0 -0
  35. {pycaption-2.2.26 → pycaption-2.2.27}/tests/__init__.py +0 -0
  36. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/__init__.py +0 -0
  37. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/dfxp.py +0 -0
  38. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/microdvd.py +0 -0
  39. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/sami.py +0 -0
  40. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/scc.py +0 -0
  41. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/srt.py +0 -0
  42. {pycaption-2.2.26 → pycaption-2.2.27}/tests/fixtures/translated_scc.py +0 -0
  43. {pycaption-2.2.26 → pycaption-2.2.27}/tests/mixins.py +0 -0
  44. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_base.py +0 -0
  45. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_dfxp.py +0 -0
  46. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_dfxp_conversion.py +0 -0
  47. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_dfxp_extras.py +0 -0
  48. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_functions.py +0 -0
  49. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_geometry.py +0 -0
  50. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_microdvd.py +0 -0
  51. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_microdvd_conversion.py +0 -0
  52. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_sami.py +0 -0
  53. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_sami_conversion.py +0 -0
  54. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc.py +0 -0
  55. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc_conversion.py +0 -0
  56. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc_translator.py +0 -0
  57. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_scc_writer.py +0 -0
  58. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_srt.py +0 -0
  59. {pycaption-2.2.26 → pycaption-2.2.27}/tests/test_srt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.2.26
3
+ Version: 2.2.27
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -16,11 +16,37 @@ from .geometry import HorizontalAlignmentEnum, Layout, Point, Size, Stretch, Uni
16
16
  # (referred to as 'cue settings' in the documentation)
17
17
  # The following pattern captures [start], [end] and [cue settings] if existent
18
18
  TIMING_LINE_PATTERN = re.compile(r"^(\S+)\s+-->\s+(\S+)(?:\s+(.*?))?\s*$")
19
+ """
20
+ Captures [start_timestamp], [end_timestamp], and optional [cue_settings]
21
+ from a WebVTT timing line.
22
+ 00:00:01.000 --> 00:00:03.000 align:start position:10%
23
+ """
19
24
  TIMESTAMP_PATTERN = re.compile(r"^(\d+):(\d{2})(:\d{2})?\.(\d{3})")
25
+ """
26
+ Parses a single WebVTT timestamp into its components.
27
+ Captures: hours (or minutes), minutes (or seconds),
28
+ optional :seconds, milliseconds.
29
+ 00:01:23.456 or 01:23.456
30
+ """
20
31
  VOICE_SPAN_PATTERN = re.compile("<v(\\.\\w+)* ([^>]*)>")
21
- OTHER_SPAN_PATTERN = re.compile(
22
- r"</?([cibuv]|ruby|rt|lang|(\d+):(\d{2})(:\d{2})?\.(\d{3})).*?>"
23
- ) # These WebVTT tags are stripped off the cues on conversion
32
+ """
33
+ Matches a voice span opening tag, capturing the speaker annotation.
34
+ The speaker name is baked into cue text as "Speaker: " prefix.
35
+ <v Roger Bingham> or <v.loud Speaker>
36
+ """
37
+ TAG_SPLIT_PATTERN = re.compile(r"(<[^>]+>)")
38
+ """
39
+ Splits cue text into alternating [text, tag, text, tag, ...] segments.
40
+ The capturing group ensures matched tags are retained in the split result.
41
+ """
42
+ KNOWN_TAGS = frozenset({"i", "b", "u", "c", "v", "lang", "ruby", "rt"})
43
+ """
44
+ The set of recognized WebVTT inline tag names.
45
+ Used for classifying opening (<i>, <c.yellow>, <lang en>) and
46
+ closing (</i>, </c>, </v>, </lang>) tags during cue text parsing.
47
+ Note: "v" is included so </v> is consumed as a closing style node
48
+ (the opening <v> is already handled by VOICE_SPAN_PATTERN).
49
+ """
24
50
  REGION_SETTING_PATTERN = re.compile(r"^([\w]+):(.+)$")
25
51
  """
26
52
  Matches a setting name (word chars) followed by colon and a value:
@@ -88,6 +114,7 @@ class WebVTTReader(BaseReader):
88
114
  start = None
89
115
  end = None
90
116
  nodes = []
117
+ open_tags = []
91
118
  layout_info = None
92
119
  found_timing = False
93
120
 
@@ -115,16 +142,18 @@ class WebVTTReader(BaseReader):
115
142
  # If we were collecting a cue, finalize and store it.
116
143
  if found_timing and nodes:
117
144
  found_timing = False
145
+ self._close_unclosed_tags(nodes, open_tags)
118
146
  caption = Caption(start, end, nodes, layout_info=layout_info)
119
147
  captions.append(caption)
120
148
  nodes = []
149
+ open_tags = []
121
150
  else:
122
151
  if found_timing:
123
152
  # We're inside a cue — this line is cue text.
124
153
  # Add a line break between multi-line cue text.
125
154
  if nodes:
126
155
  nodes.append(CaptionNode.create_break())
127
- nodes.append(CaptionNode.create_text(self._decode(line)))
156
+ nodes.extend(self._parse_cue_text(line, open_tags))
128
157
  else:
129
158
  # Outside a cue: cue identifiers, NOTE blocks,
130
159
  # or other metadata — skip silently.
@@ -132,6 +161,7 @@ class WebVTTReader(BaseReader):
132
161
 
133
162
  # File may not end with a blank line — emit any remaining cue
134
163
  if nodes:
164
+ self._close_unclosed_tags(nodes, open_tags)
135
165
  caption = Caption(start, end, nodes, layout_info=layout_info)
136
166
  captions.append(caption)
137
167
 
@@ -200,26 +230,194 @@ class WebVTTReader(BaseReader):
200
230
  # Timestamp of the form [minutes]:[seconds].[milliseconds]
201
231
  return microseconds(0, m[0], m[1], m[3])
202
232
 
203
- def _decode(self, s):
233
+ def _parse_cue_text(self, line, open_tags=None):
234
+ """Parse a single line of WebVTT cue text into a list of CaptionNodes.
235
+
236
+ Converts inline markup tags into CaptionNode.STYLE open/close pairs
237
+ and text content into CaptionNode.TEXT nodes.
238
+
239
+ Voice tags are handled before splitting (baked into text as
240
+ "Speaker: " prefix), matching the legacy behavior.
241
+
242
+ :param line: A single line of cue text (raw WebVTT)
243
+ :param open_tags: Mutable list tracking unclosed tag content dicts
244
+ across lines within a cue. Callers pass the same list for each
245
+ line so unclosed tags can be auto-closed at cue end.
246
+ :returns: list of CaptionNode
204
247
  """
205
- Convert cue text from WebVTT XML-like format to plain unicode.
206
- :type s: str
248
+ line = line.strip()
249
+ # \2 is the speaker name capture group; replaces the full <v ...> tag
250
+ # with "Speaker: " so voice identity is preserved as plain text.
251
+ line = VOICE_SPAN_PATTERN.sub(r"\2: ", line)
252
+
253
+ nodes = []
254
+ # re.split() with a capturing group guarantees alternating segments:
255
+ # even indices = text (possibly ""), odd indices = captured tags.
256
+ # e.g. "Hello <i>world</i>" -> ["Hello ", "<i>", "world", "</i>", ""]
257
+ parts = TAG_SPLIT_PATTERN.split(line)
258
+
259
+ for i, part in enumerate(parts):
260
+ if not part:
261
+ continue
262
+
263
+ if i % 2 == 0:
264
+ # Even indices are plain text — decode entities and store
265
+ text = self._decode_entities(part)
266
+ if text:
267
+ nodes.append(CaptionNode.create_text(text))
268
+ else:
269
+ # Odd indices are tags — classify into style/text nodes
270
+ node = self._classify_tag(part)
271
+ if node is not None:
272
+ nodes.append(node)
273
+ if open_tags is not None and node.type_ == CaptionNode.STYLE:
274
+ if "timestamp" in node.content:
275
+ pass
276
+ elif node.start:
277
+ open_tags.append(node.content)
278
+ else:
279
+ self._pop_matching_tag(open_tags, node.content)
280
+
281
+ return nodes
282
+
283
+ @staticmethod
284
+ def _pop_matching_tag(open_tags, content):
285
+ """Remove the most recent matching open tag from the stack.
286
+
287
+ Matches by tag-type key (e.g. "classes", "lang") rather than full
288
+ dict equality, because closing tags lack the opener's value
289
+ (</c> -> {'classes': []} vs <c.yellow> -> {'classes': ['yellow']}).
207
290
  """
208
- s = s.strip()
209
- # Covert voice span
210
- s = VOICE_SPAN_PATTERN.sub("\\2: ", s)
211
- # TODO: Add support for other WebVTT tags. For now just strip them
212
- # off the text.
213
- s = OTHER_SPAN_PATTERN.sub("", s)
214
- # Replace WebVTT special XML codes with plain unicode values
215
- s = s.replace("&lt;", "<")
216
- s = s.replace("&gt;", ">")
217
- s = s.replace("&lrm;", "\u200e")
218
- s = s.replace("&rlm;", "\u200f")
219
- s = s.replace("&nbsp;", "\u00a0")
220
- # Must do ampersand last
221
- s = s.replace("&amp;", "&")
222
- return s
291
+ keys = set(content.keys())
292
+ for j in range(len(open_tags) - 1, -1, -1):
293
+ if set(open_tags[j].keys()) == keys:
294
+ open_tags.pop(j)
295
+ break
296
+
297
+ @staticmethod
298
+ def _close_unclosed_tags(nodes, open_tags):
299
+ """Emit closing STYLE nodes for any tags left open at cue end.
300
+
301
+ Per W3C WebVTT spec, unclosed tags are implicitly closed at the
302
+ end of the cue. Closing order is reverse of opening (LIFO).
303
+ """
304
+ for content in reversed(open_tags):
305
+ nodes.append(CaptionNode.create_style(False, content))
306
+
307
+ def _classify_tag(self, tag_str):
308
+ """Classify a captured tag string and return the appropriate
309
+ CaptionNode.
310
+
311
+ Returns a STYLE node for recognized tags, a TEXT node for
312
+ unrecognized angle-bracket content (e.g. "<LAUGHING>"), so that
313
+ arbitrary text in angle brackets is preserved rather than dropped.
314
+
315
+ :param tag_str: The raw tag string, e.g. "<i>", "</b>", "<c.yellow>"
316
+ :returns: CaptionNode or None
317
+ """
318
+ # Strip the angle brackets: "<i>" -> "i", "</b>" -> "/b"
319
+ inner = tag_str[1:-1]
320
+
321
+ # Closing tag: starts with "/"
322
+ if inner.startswith("/"):
323
+ tag_name = inner[1:]
324
+ if tag_name in KNOWN_TAGS:
325
+ content = self._tag_content(tag_name)
326
+ if not content:
327
+ return None
328
+ return CaptionNode.create_style(False, content)
329
+ else:
330
+ text = self._decode_entities(tag_str)
331
+ return CaptionNode.create_text(text)
332
+
333
+ # Timestamp tag: e.g. "00:01:23.456"
334
+ m = TIMESTAMP_PATTERN.match(inner)
335
+ if m:
336
+ groups = m.groups()
337
+ if groups[2] is not None:
338
+ secs = groups[2].replace(":", "")
339
+ us = microseconds(
340
+ groups[0], groups[1], secs, groups[3]
341
+ )
342
+ else:
343
+ us = microseconds(0, groups[0], groups[1], groups[3])
344
+ return CaptionNode.create_style(True, {"timestamp": us})
345
+
346
+ # Opening tag: extract tag name, optional .classes, optional annotation
347
+ # Examples: "i", "c.yellow", "lang en"
348
+ tag_name, class_suffix, annotation = self._parse_opening_tag(inner)
349
+ if tag_name in KNOWN_TAGS:
350
+ content = self._tag_content(tag_name, class_suffix, annotation)
351
+ return CaptionNode.create_style(True, content)
352
+
353
+ # Unrecognized — treat as literal text (e.g. "<LAUGHING & WHOOPS!>")
354
+ text = self._decode_entities(tag_str)
355
+ return CaptionNode.create_text(text)
356
+
357
+ @staticmethod
358
+ def _parse_opening_tag(inner):
359
+ """Parse the inside of an opening tag into
360
+ (name, class_suffix, annotation).
361
+
362
+ "i" -> ("i", None, None)
363
+ "c.yellow" -> ("c", "yellow", None)
364
+ "lang en" -> ("lang", None, "en")
365
+ "c.a.b" -> ("c", "a.b", None)
366
+
367
+ :param inner: Tag content without angle brackets
368
+ :returns: tuple (tag_name, class_suffix, annotation)
369
+ """
370
+ # Split on first space for annotation (e.g. "lang en-US")
371
+ if " " in inner:
372
+ tag_part, annotation = inner.split(" ", 1)
373
+ else:
374
+ tag_part, annotation = inner, None
375
+
376
+ # Split on first dot for class suffix (e.g. "c.yellow.highlight")
377
+ if "." in tag_part:
378
+ tag_name, class_suffix = tag_part.split(".", 1)
379
+ else:
380
+ tag_name, class_suffix = tag_part, None
381
+
382
+ return tag_name, class_suffix, annotation
383
+
384
+ @staticmethod
385
+ def _tag_content(tag_name, class_suffix=None, annotation=None):
386
+ """Build the style content dict for a tag.
387
+
388
+ :returns: dict
389
+ """
390
+ if tag_name == "i":
391
+ return {"italics": True}
392
+ elif tag_name == "b":
393
+ return {"bold": True}
394
+ elif tag_name == "u":
395
+ return {"underline": True}
396
+ elif tag_name == "c":
397
+ classes = class_suffix.split(".") if class_suffix else []
398
+ return {"classes": classes}
399
+ elif tag_name == "lang":
400
+ return {"lang": annotation.strip() if annotation else ""}
401
+ elif tag_name == "ruby":
402
+ return {"ruby": True}
403
+ elif tag_name == "rt":
404
+ return {"ruby_text": True}
405
+ return {}
406
+
407
+ @staticmethod
408
+ def _decode_entities(text):
409
+ """Decode WebVTT character entities in a text segment.
410
+
411
+ :type text: str
412
+ :rtype: str
413
+ """
414
+ text = text.replace("&lt;", "<")
415
+ text = text.replace("&gt;", ">")
416
+ text = text.replace("&lrm;", "‎")
417
+ text = text.replace("&rlm;", "‏")
418
+ text = text.replace("&nbsp;", " ")
419
+ text = text.replace("&amp;", "&")
420
+ return text
223
421
 
224
422
  def _parse_regions(self, lines):
225
423
  """Parse REGION blocks from the file header area.
@@ -574,20 +772,24 @@ class WebVTTWriter(BaseWriter):
574
772
  node.content, caption_set
575
773
  )
576
774
 
775
+ has_text_style = False
577
776
  styles = ["italics", "underline", "bold"]
578
777
  if not node.start:
579
778
  styles.reverse()
580
779
 
581
780
  for style in styles:
582
781
  if style in resulting_style and resulting_style[style]:
782
+ has_text_style = True
583
783
  tags = self._convert_style_to_text_tag(style)
584
784
  if node.start:
585
785
  s += tags[0]
586
786
  else:
587
787
  s += tags[1]
588
788
 
589
- # TODO: Refactor pycaption and eliminate the concept of a
590
- # "Style node"
789
+ if not has_text_style:
790
+ s += self._convert_structural_tag(
791
+ node.content, node.start
792
+ )
591
793
  elif node.type_ == CaptionNode.BREAK:
592
794
  if i > 0 and nodes[i - 1].type_ != CaptionNode.TEXT:
593
795
  s += "&nbsp;"
@@ -622,3 +824,34 @@ class WebVTTWriter(BaseWriter):
622
824
  # s = s.replace('\u200f', '&rlm;')
623
825
  # s = s.replace('\u00a0', '&nbsp;')
624
826
  return s
827
+
828
+ def _convert_structural_tag(self, content, is_start):
829
+ """Convert a structural style node back into a WebVTT tag string.
830
+
831
+ Structural tags are WebVTT-specific (class, lang, ruby, timestamp).
832
+ Other writers silently ignore these keys.
833
+
834
+ :param content: The style node's content dict
835
+ :param is_start: True for opening tag, False for closing
836
+ :returns: str
837
+ """
838
+ if "lang" in content:
839
+ if is_start:
840
+ lang = content["lang"]
841
+ return f"<lang {lang}>" if lang else "<lang>"
842
+ return "</lang>"
843
+ elif "classes" in content:
844
+ if is_start:
845
+ classes = content["classes"]
846
+ class_str = "." + ".".join(classes) if classes else ""
847
+ return f"<c{class_str}>"
848
+ return "</c>"
849
+ elif "ruby" in content:
850
+ return "<ruby>" if is_start else "</ruby>"
851
+ elif "ruby_text" in content:
852
+ return "<rt>" if is_start else "</rt>"
853
+ elif "timestamp" in content:
854
+ if is_start:
855
+ return f"<{self._timestamp(content['timestamp'])}>"
856
+ return ""
857
+ return ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.2.26
3
+ Version: 2.2.27
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.9.4"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.2.26",
23
+ version="2.2.27",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -169,5 +169,7 @@ from tests.fixtures.webvtt import ( # noqa: F401
169
169
  sample_webvtt_output_long_cue,
170
170
  sample_webvtt_timestamps,
171
171
  sample_webvtt_with_cue_settings,
172
+ sample_webvtt_with_inline_style,
173
+ sample_webvtt_with_structural_tags,
172
174
  webvtt_from_dfxp_with_conflicting_align,
173
175
  )
@@ -380,3 +380,38 @@ Test zero padded and two digit timestamps without hours
380
380
 
381
381
  01:01:01.001 --> 10:10:10.100
382
382
  Test zero padded and two digit timestamps without hours"""
383
+
384
+
385
+ @pytest.fixture(scope="session")
386
+ def sample_webvtt_with_inline_style():
387
+ return """\
388
+ WEBVTT
389
+
390
+ 00:00:01.000 --> 00:00:03.000
391
+ Hello <i>world</i>
392
+
393
+ 00:00:04.000 --> 00:00:06.000
394
+ <b>bold</b> and <u>underlined</u>
395
+
396
+ 00:00:07.000 --> 00:00:09.000
397
+ <b><i>nested bold italic</i></b>
398
+ """
399
+
400
+
401
+ @pytest.fixture(scope="session")
402
+ def sample_webvtt_with_structural_tags():
403
+ return """\
404
+ WEBVTT
405
+
406
+ 00:00:01.000 --> 00:00:03.000
407
+ <c.yellow>colored text</c>
408
+
409
+ 00:00:04.000 --> 00:00:06.000
410
+ <lang fr>Bonjour le monde</lang>
411
+
412
+ 00:00:07.000 --> 00:00:09.000
413
+ <ruby>base text<rt>annotation</rt></ruby>
414
+
415
+ 00:00:10.000 --> 00:00:15.000
416
+ Hello <00:00:12.000>world
417
+ """