Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/data_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ The class hierarchy of the canonical model is summarized the following figure:
: (Span | Br | Text)*

Ruby
: Rb? Rt?
| Rb? Rp Rt? Rp
: (Rb Rt)?
| Rb Rp Rt Rp
| Rbc Rtc Rtc?

Rbc
Expand Down
9 changes: 9 additions & 0 deletions src/main/python/ttconv/filters/isd/merge_regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
class RegionsMergingISDFilter(ISDFilter):
"""Filter for merging ISD document regions into a single region"""

def __init__(self, is_style_copy: bool = False):
"""If `is_style_copy` is true, the merged region inherits the properties of the first region"""
self.is_style_copy = is_style_copy

def process(self, isd: ISD):
"""Merges the ISD document regions"""
LOGGER.debug("Apply regions merging filter to ISD.")
Expand Down Expand Up @@ -70,6 +74,11 @@ def process(self, isd: ISD):
isd.remove_region(region_id)

target_region = ISD.Region("_".join(region_ids), isd)

if self.is_style_copy:
original_regions[0].copy_to(target_region)
target_region.set_id("_".join(region_ids))

target_region.push_child(target_body)

isd.put_region(target_region)
20 changes: 19 additions & 1 deletion src/main/python/ttconv/isd.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,10 @@ def _process_element(
child_element
)

if isd_element_child is None and isinstance(child_element, (model.Rb, model.Rbc, model.Rt, model.Rtc, model.Rp)):
# ruby structure elements are never pruned
isd_element_child = child_element.__class__(isd)

if isd_element_child is not None:
isd_element_children.append(isd_element_child)

Expand All @@ -687,9 +691,15 @@ def _process_element(

# prune or keep the element

if isinstance(isd_element, (model.Br, model.Text,model.Rb, model.Rbc)):
# ruby spans are not pruned to preserve the model structure
if isinstance(isd_element, (model.Br, model.Text, model.Rb, model.Rbc, model.Rt, model.Rtc, model.Rp)):
return isd_element

# ruby elements are pruned if they consists of only empty elements
if isinstance(isd_element, model.Ruby):
if _is_ruby_structure_empty(isd_element):
return None

if isd_element.has_children():
return isd_element

Expand All @@ -701,6 +711,14 @@ def _process_element(

return None

def _is_ruby_structure_empty(ruby: model.ContentElement) -> bool:
'''Returns whether the ruby does not contain text elements'''
for child in ruby:
if not isinstance(child, (model.Rb, model.Rbc, model.Rt, model.Rtc, model.Rp)) or not _is_ruby_structure_empty(child):
return False

return True

def _prune_empty_spans(element: model.ContentElement):
children = list(element)
for child in children:
Expand Down
19 changes: 19 additions & 0 deletions src/main/python/ttconv/vtt/cue.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ class TextAlignment(Enum):
center = "center"
right = "right"

class WritingDirection(Enum):
"""WebVTT writing direction cue setting"""
horizontal = "horizontal"
vertical_growing_left = "rl"
vertical_growing_right = "lr"

_EOL_SEQ_RE = re.compile(r"\n{2,}")

def __init__(self, identifier: Optional[int] = None):
Expand All @@ -58,6 +64,7 @@ def __init__(self, identifier: Optional[int] = None):
self._line: int = None
self._linealign: VttCue.LineAlignment = None
self._textalign: VttCue.TextAlignment = None
self._writing_direction: VttCue.WritingDirection = VttCue.WritingDirection.horizontal

def set_begin(self, offset: Fraction):
"""Sets the paragraph begin time code"""
Expand Down Expand Up @@ -100,6 +107,14 @@ def set_textalign(self, textalign: TextAlignment):
"""Sets the WebVTT text alignment cue setting"""
self._textalign = textalign

def set_writing_direction(self, writing_direction: WritingDirection):
"""Sets the WebVTT writing direction cue setting"""
self._writing_direction = writing_direction

def get_writing_direction(self) -> WritingDirection:
"""Return the WebVTT writing direction cue setting"""
return self._writing_direction

def is_only_whitespace_or_empty(self):
"""Returns whether the paragraph text contains only whitespace or is empty"""
return len(self._text) == 0 or self._text.isspace()
Expand Down Expand Up @@ -134,6 +149,10 @@ def __str__(self) -> str:
# cue timing
t += f"{self._begin} --> {self._end}"

# cue writing direction
if self._writing_direction is not VttCue.WritingDirection.horizontal:
t += f" vertical:{self._writing_direction.value}"

# cue text position
if self._textalign is not None:
t += f" align:{self._textalign.value}"
Expand Down
6 changes: 6 additions & 0 deletions src/main/python/ttconv/vtt/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
BG_COLOR_TAG_IN = "<c.{}>"
BG_COLOR_TAG_OUT = "</c>"

RUBY_TAG_IN = "<ruby>"
RUBY_TAG_OUT = "</ruby>"

RT_TAG_IN = "<rt>"
RT_TAG_OUT = "</rt>"

DEFAULT_TEXT_COLORS: Dict[str, str] = {
"#ffffffff": "white",
"#00ff00ff": "lime",
Expand Down
104 changes: 91 additions & 13 deletions src/main/python/ttconv/vtt/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import dataclasses
import logging
from fractions import Fraction
from typing import Dict, List, Optional
from typing import Dict, Iterator, List, Optional

import ttconv.model as model
from ttconv.vtt.config import VTTWriterConfiguration
Expand All @@ -42,7 +42,7 @@
from ttconv.vtt.cue import VttCue
from ttconv.vtt.css_class import CssClass
from ttconv.style_properties import DirectionType, ExtentType, PositionType, StyleProperties, FontStyleType, NamedColors, \
FontWeightType, TextDecorationType, DisplayAlignType, TextAlignType
FontWeightType, TextDecorationType, DisplayAlignType, TextAlignType, WritingModeType

LOGGER = logging.getLogger(__name__)

Expand All @@ -62,7 +62,7 @@ def __init__(self, config: VTTWriterConfiguration):
self._filters = []

if not self._config.line_position:
self._filters.append(RegionsMergingISDFilter())
self._filters.append(RegionsMergingISDFilter(is_style_copy=True))

self._filters.append(ParagraphsMergingISDFilter())

Expand All @@ -77,7 +77,8 @@ def __init__(self, config: VTTWriterConfiguration):
TextDecorationType(False, False, False)
],
StyleProperties.Color: [],
StyleProperties.BackgroundColor: []
StyleProperties.BackgroundColor: [],
StyleProperties.WritingMode: []
}

if self._config.line_position:
Expand Down Expand Up @@ -173,6 +174,61 @@ def process_inline_element(self, element: model.ContentElement, begin: Fraction,
for elem in list(element):
self.process_inline_element(elem, begin, end)

if isinstance(element, model.Ruby):
self.process_ruby(element, begin, end)

@staticmethod
def _iter_without_rp(element: model.ContentElement) -> Iterator[model.ContentElement]:
"""Iterates over the children of `element`, ignoring rp elements"""
for child in element:
if isinstance(child, model.Rp):
LOGGER.error("rp elements are not supported; ignoring their contents")
else:
yield child

def process_ruby(self, element: model.Ruby, begin: Fraction, end: Optional[Fraction]):
"""Converts ruby element to VTT content, where each rb/rt pair results in a separate VTT ruby element"""

children = list(self._iter_without_rp(element))

if isinstance(children[0], model.Rb):
# rb rt
rbs = [children[0]]
rts = [children[1]]
else:
# rbc rtc rtc?
if len(children) > 2:
LOGGER.error("Ruby elements with multiple rtc elements are not supported; ignoring all but the first rtc")

rbs = list(children[0])
rts = list(self._iter_without_rp(children[1]))

# rt elements without a matching rb element are ignored
if len(rts) > len(rbs):
LOGGER.error("Ignoring %s rt elements without matching rb elements", len(rts) - len(rbs))

for i, rb in enumerate(rbs):
rt = rts[i] if i < len(rts) else None

if rt is None or not rt.has_children():
# base without annotation
for elem in list(rb):
self.process_inline_element(elem, begin, end)
continue

self._paragraphs[-1].append_text(style.RUBY_TAG_IN)

for elem in list(rb):
self.process_inline_element(elem, begin, end)

self._paragraphs[-1].append_text(style.RT_TAG_IN)

for elem in list(rt):
self.process_inline_element(elem, begin, end)

self._paragraphs[-1].append_text(style.RT_TAG_OUT)
self._paragraphs[-1].append_text(style.RUBY_TAG_OUT)

def process_p(self, region: ISD.Region, element: model.P, begin: Fraction, end: Optional[Fraction]):
"""Process p element"""

Expand All @@ -182,20 +238,42 @@ def process_p(self, region: ISD.Region, element: model.P, begin: Fraction, end:
cue.set_begin(begin)
cue.set_end(end)

writing_mode = region.get_style(StyleProperties.WritingMode)

if writing_mode == WritingModeType.tbrl:
cue.set_writing_direction(VttCue.WritingDirection.vertical_growing_left)
elif writing_mode == WritingModeType.tblr:
cue.set_writing_direction(VttCue.WritingDirection.vertical_growing_right)

if self._config.line_position:
display_align = region.get_style(StyleProperties.DisplayAlign)
position: PositionType = region.get_style(StyleProperties.Position)
extent: ExtentType = region.get_style(StyleProperties.Extent)

if display_align == DisplayAlignType.after:
cue.set_line(round(position.v_offset.value + extent.height.value))
cue.set_align(VttCue.LineAlignment.end)
elif display_align == DisplayAlignType.before:
cue.set_line(round(position.v_offset.value))
cue.set_align(VttCue.LineAlignment.start)

if cue.get_writing_direction() is not VttCue.WritingDirection.horizontal:
# the line position of vertical cues is measured from the left edge of the viewport
is_before_left = cue.get_writing_direction() is VttCue.WritingDirection.vertical_growing_right

if display_align == DisplayAlignType.center:
cue.set_line(round(position.h_offset.value + extent.width.value / 2))
cue.set_align(VttCue.LineAlignment.center)
elif (display_align == DisplayAlignType.before) == is_before_left:
cue.set_line(round(position.h_offset.value))
cue.set_align(VttCue.LineAlignment.start)
else:
cue.set_line(round(position.h_offset.value + extent.width.value))
cue.set_align(VttCue.LineAlignment.end)

else:
cue.set_line(round(position.v_offset.value + extent.height.value / 2))
cue.set_align(VttCue.LineAlignment.center)
if display_align == DisplayAlignType.after:
cue.set_line(round(position.v_offset.value + extent.height.value))
cue.set_align(VttCue.LineAlignment.end)
elif display_align == DisplayAlignType.before:
cue.set_line(round(position.v_offset.value))
cue.set_align(VttCue.LineAlignment.start)
else:
cue.set_line(round(position.v_offset.value + extent.height.value / 2))
cue.set_align(VttCue.LineAlignment.center)

if self._config.text_align:
direction = element.get_style(StyleProperties.Direction)
Expand Down
34 changes: 34 additions & 0 deletions src/test/python/test_filter_merge_regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from ttconv.filters.isd.merge_regions import RegionsMergingISDFilter
from ttconv.isd import ISD
from ttconv.model import P, Body, Div, Span, Text, ContentElement
from ttconv.style_properties import DisplayAlignType, StyleProperties, WritingModeType


class RegionsMergingFilterTest(unittest.TestCase):
Expand Down Expand Up @@ -98,5 +99,38 @@ def test_merging_regions(self):
text = self._get_text_from_children(divs[1])
self.assertEqual("Is there anyone here?", text)

def _merge_styled_regions(self, regions_merging_filter: RegionsMergingISDFilter):
isd = ISD(None)

r1 = ISD.Region("r1", isd)
r1.push_child(self._get_filled_body(isd, "Hello world"))
r1.set_style(StyleProperties.WritingMode, WritingModeType.tbrl)
r1.set_style(StyleProperties.DisplayAlign, DisplayAlignType.after)

r2 = ISD.Region("r2", isd)
r2.push_child(self._get_filled_body(isd, "Is there anyone here?"))
r2.set_style(StyleProperties.WritingMode, WritingModeType.tblr)

isd.put_region(r1)
isd.put_region(r2)

regions_merging_filter.process(isd)

merged_region = isd.get_region("r1_r2")
self.assertIsNotNone(merged_region)
self.assertEqual("r1_r2", merged_region.get_id())

return merged_region

def test_merging_regions_no_style_copy(self):
merged_region = self._merge_styled_regions(RegionsMergingISDFilter())
self.assertIsNone(merged_region.get_style(StyleProperties.WritingMode))
self.assertIsNone(merged_region.get_style(StyleProperties.DisplayAlign))

def test_merging_regions_style_copy(self):
merged_region = self._merge_styled_regions(RegionsMergingISDFilter(is_style_copy=True))
self.assertEqual(WritingModeType.tbrl, merged_region.get_style(StyleProperties.WritingMode))
self.assertEqual(DisplayAlignType.after, merged_region.get_style(StyleProperties.DisplayAlign))

if __name__ == '__main__':
unittest.main()
Loading