From 96cf63c0f6aa2a4ea855706df63d2eeadc7dddcb Mon Sep 17 00:00:00 2001 From: Pierre-Anthony Lemieux Date: Fri, 29 Dec 2023 08:36:02 -0800 Subject: [PATCH] Add support for document filters https://github.com/sandflow/ttconv/issues/417 --- README.md | 69 +++- setup.py | 2 +- src/main/python/ttconv/filters/__init__.py | 36 -- .../python/ttconv/filters/doc/__init__.py | 37 ++ src/main/python/ttconv/filters/doc/lcd.py | 261 ++++++++++++++ .../python/ttconv/filters/document_filter.py | 59 ++++ .../python/ttconv/filters/isd/__init__.py | 29 ++ .../{ => isd}/default_style_properties.py | 4 +- .../filters/{ => isd}/merge_paragraphs.py | 4 +- .../ttconv/filters/{ => isd}/merge_regions.py | 4 +- .../filters/isd/supported_style_properties.py | 51 +++ src/main/python/ttconv/filters/isd_filter.py | 35 ++ .../ttconv/filters/remove_animations.py | 52 +++ .../filters/supported_style_properties.py | 46 +-- .../python/ttconv/imsc/style_properties.py | 13 +- src/main/python/ttconv/imsc/utils.py | 57 --- src/main/python/ttconv/srt/reader.py | 2 +- src/main/python/ttconv/srt/writer.py | 20 +- src/main/python/ttconv/tt.py | 24 ++ src/main/python/ttconv/utils.py | 84 +++++ src/main/python/ttconv/vtt/writer.py | 16 +- src/test/python/test_config.py | 4 + .../test_filter_default_style_properties.py | 6 +- .../python/test_filter_merge_paragraphs.py | 4 +- src/test/python/test_filter_merge_regions.py | 4 +- .../test_filter_supported_style_properties.py | 7 +- src/test/python/test_imsc_color_parser.py | 2 +- src/test/python/test_lcd_filter.py | 327 ++++++++++++++++++ src/test/python/test_tt.py | 18 + 29 files changed, 1104 insertions(+), 173 deletions(-) create mode 100644 src/main/python/ttconv/filters/doc/__init__.py create mode 100644 src/main/python/ttconv/filters/doc/lcd.py create mode 100644 src/main/python/ttconv/filters/document_filter.py create mode 100644 src/main/python/ttconv/filters/isd/__init__.py rename src/main/python/ttconv/filters/{ => isd}/default_style_properties.py (96%) rename src/main/python/ttconv/filters/{ => isd}/merge_paragraphs.py (97%) rename src/main/python/ttconv/filters/{ => isd}/merge_regions.py (96%) create mode 100644 src/main/python/ttconv/filters/isd/supported_style_properties.py create mode 100644 src/main/python/ttconv/filters/isd_filter.py create mode 100644 src/main/python/ttconv/filters/remove_animations.py create mode 100644 src/main/python/ttconv/utils.py create mode 100644 src/test/python/test_lcd_filter.py diff --git a/README.md b/README.md index 32c94a6b..f30ff9c2 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,15 @@ tt convert -i -o * `--itype`: `TTML` | `SCC` | `STL` | `SRT` (extrapolated from the filename, if omitted) * `--otype`: `TTML` | `SRT` | `VTT` (extrapolated from the filename, if omitted) -* `--config` and `--config_file`: JSON dictionaries with the following members: - * `"general": JSON object`: General configuration options (see below) - * `"imsc_writer": JSON object`: IMSC Writer configuration options (see below) - * `"stl_reader": JSON object`: STL Reader configuration options (see below) - * `"vtt_writer": JSON object`: WebVTT Writer configuration options (see below) - * `"srt_writer": JSON object`: SRT Writer configuration options (see below) - * `"scc_reader": JSON object`: SCC Reader configuration options (see below) +* `--filter`: specifies by name a filter to be applied to the content +* `--config` and `--config_file`: JSON dictionary where each property specifies + (optional) configuration parameters for readers, writers and filters. Example: -`tt convert -i <.scc file> -o <.ttml file> --itype SCC --otype TTML --config '{"general": {"progress_bar":false, "log_level":"WARN"}}'` +`tt convert -i <.scc file> -o <.ttml file> --itype SCC --otype TTML --filter lcd --config '{"general": {"progress_bar":false, "log_level":"WARN"}, "lcd": {"bg_color": "transparent", "color": "#FF0000"}}'` -### General configuration +### General configuration (`"general"`) #### progress_bar @@ -109,7 +105,7 @@ Example: `"document_lang": "es-419"` Default: `None` -### IMSC Writer configuration +### IMSC Writer configuration (`"imsc_writer"`) ### time_format @@ -131,7 +127,7 @@ Example: `--config '{"general": {"progress_bar":false, "log_level":"WARN"}, "imsc_writer": {"time_format":"clock_time_with_frames", "fps": "25/1"}}'` -### STL Reader configuration +### STL Reader configuration (`"stl_reader"`) #### disable_fill_line_gap @@ -173,7 +169,7 @@ Specifies a maximum number of rows for open subtitles, either the MNR field of t Default: `23` -### SRT Writer configuration +### SRT Writer configuration (`"srt_writer"`) #### text_formatting @@ -183,7 +179,7 @@ Default: `23` Default: `true` -### VTT Writer configuration +### VTT Writer configuration (`"vtt_writer"`) #### line_position @@ -220,7 +216,52 @@ text alignment. Default: `"auto"` -### Library +### LCD filter configuration (`"lcd"`) + +#### Description + +The LCD filter merges regions and removes all text formatting with the exception +of color and text alignment. + +#### safe_area + +`"safe_area" : ` + +Specifies the safe area (as a percentage of the height and width of the root container) + +Default: `10` + +#### color + +`"color" : | null` + +If not `null`, overrides text color. The syntax of `TTML color` is +specified at . + +Default: `null` + +Examples: `"#FFFFFF"` (white), `"white"` + +#### bg_color + +`"bg_color" : ` + +If not `null`, overrides the background color. The syntax of `TTML color` is +specified at . + +Default: `null` + +Examples: `"#FF0000"` (red), `"transparent"`, `"black"` + +#### preserve_text_align + +`"preserve_text_align" : true | false` + +If `true`, text alignment is preserved, otherwise text is centered. + +Default: `false` + +## Library The overall architecture of the library is as follows: diff --git a/setup.py b/setup.py index 074b662f..b85b4156 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='ttconv', - version='1.0.8', + version='1.1.0-beta.1', description='Library for conversion of common timed text formats', long_description=long_description, long_description_content_type='text/markdown', diff --git a/src/main/python/ttconv/filters/__init__.py b/src/main/python/ttconv/filters/__init__.py index cb3bee33..e69de29b 100644 --- a/src/main/python/ttconv/filters/__init__.py +++ b/src/main/python/ttconv/filters/__init__.py @@ -1,36 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- - -# Copyright (c) 2020, Sandflow Consulting LLC -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -"""Data model filter""" - -from ttconv.isd import ISD - - -class Filter: - """Abstract base class for filters""" - - def process(self, isd: ISD): - """Process the specified ISD and returns it.""" - raise NotImplementedError diff --git a/src/main/python/ttconv/filters/doc/__init__.py b/src/main/python/ttconv/filters/doc/__init__.py new file mode 100644 index 00000000..d6c21d3c --- /dev/null +++ b/src/main/python/ttconv/filters/doc/__init__.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2023, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Collects document instance filters""" + +import importlib +import pkgutil +import os.path +import sys + +# registers all document instance filters + +for importer, package_name, _ in pkgutil.iter_modules([os.path.dirname(__file__)]): + full_name = f"{__name__}.{package_name}" + importlib.import_module(full_name) diff --git a/src/main/python/ttconv/filters/doc/lcd.py b/src/main/python/ttconv/filters/doc/lcd.py new file mode 100644 index 00000000..d9a446c9 --- /dev/null +++ b/src/main/python/ttconv/filters/doc/lcd.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2023, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Defines the Least common denominator (LCD) filter.""" + +from __future__ import annotations +import logging +import typing +from dataclasses import dataclass, field +from numbers import Number + +from ttconv.config import ModuleConfiguration +from ttconv.filters.document_filter import DocumentFilter +from ttconv.filters.remove_animations import RemoveAnimationFilter +from ttconv.filters.supported_style_properties import SupportedStylePropertiesFilter +from ttconv.isd import StyleProcessors +from ttconv.model import ContentDocument, ContentElement, Region, P +from ttconv.style_properties import TextAlignType, ColorType, CoordinateType, DisplayAlignType, ExtentType, LengthType, StyleProperties, WritingModeType, NamedColors +import ttconv.utils + +LOGGER = logging.getLogger(__name__) + +def _replace_regions(element: ContentElement, region_aliases: typing.Mapping[Region, Region]): + merged_region = region_aliases.get(element.get_region()) + if merged_region is not None: + element.set_region(merged_region) + for child in element: + _replace_regions(child, region_aliases) + +def _apply_bg_color(element: ContentElement, bg_color: ColorType): + if isinstance(element, P): + element.set_style(StyleProperties.BackgroundColor, bg_color) + else: + for child in element: + _apply_bg_color(child, bg_color) + +def _safe_area_decoder(s: Number) -> int: + safe_area = int(s) + if 30 < safe_area < 0: + raise ValueError("Safe area must be an integer between 0 and 30") + return safe_area + +def _color_decoder(s: typing.Optional[ColorType]) -> typing.Optional[ColorType]: + if s is None: + return None + + if not isinstance(s, str): + raise ValueError("Color specification must be a string") + + return ttconv.utils.parse_color(s) + +@dataclass +class LCDDocFilterConfig(ModuleConfiguration): + """Configuration class for the Least common denominator (LCD) filter""" + + @classmethod + def name(cls): + return "lcd" + + # specifies the safe area as an integer percentage + safe_area: typing.Optional[int] = field(default=10, metadata={"decoder": _safe_area_decoder}) + + # preserve text alignment + preserve_text_align: typing.Optional[bool] = field(default=False, metadata={"decoder": bool}) + + # overrides the text color + color: typing.Optional[ColorType] = field(default=None, metadata={"decoder": _color_decoder}) + + # overrides the background color + bg_color: typing.Optional[ColorType] = field(default=None, metadata={"decoder": _color_decoder}) + +class LCDDocFilter(DocumentFilter): + """Merges regions and removes all text formatting with the exception of color + and text alignment.""" + + @classmethod + def get_config_class(cls) -> ModuleConfiguration: + return LCDDocFilterConfig + + def __init__(self, config: LCDDocFilterConfig): + super().__init__(config) + + def process(self, doc: ContentDocument) -> ContentDocument: + + # clean-up styles + + supported_styles = { + StyleProperties.DisplayAlign: [], + StyleProperties.Extent: [], + StyleProperties.Origin: [], + StyleProperties.Position: [] + } + + if self.config.preserve_text_align: + supported_styles.update({StyleProperties.TextAlign: []}) + + if self.config.color is None: + supported_styles.update({StyleProperties.Color: []}) + + if self.config.bg_color is None: + supported_styles.update({StyleProperties.BackgroundColor: []}) + + style_filter = SupportedStylePropertiesFilter(supported_styles) + + style_filter.process_initial_values(doc) + + if doc.get_body() is not None: + style_filter.process_element(doc.get_body()) + + # clean-up animations + + animation_filter = RemoveAnimationFilter() + + if doc.get_body() is not None: + animation_filter.process_element(doc.get_body()) + + # clean-up regions + + initial_extent = doc.get_initial_value(StyleProperties.Extent) + initial_origin = doc.get_initial_value(StyleProperties.Origin) + initial_writing_mode = doc.get_initial_value(StyleProperties.WritingMode) + initial_display_align = doc.get_initial_value(StyleProperties.DisplayAlign) + + retained_regions = dict() + replaced_regions = dict() + + for region in doc.iter_regions(): + + # cleanup animations + animation_filter.process_element(region) + + # cleanup styles + style_filter.process_element(region) + + # compute origin + if (region.get_style(StyleProperties.Origin)) is not None: + StyleProcessors.Origin.compute(None, region) + + if (region.get_style(StyleProperties.Position)) is not None: + StyleProcessors.Position.compute(None, region) + region.set_style(StyleProperties.Position, None) + + if region.get_style(StyleProperties.Origin) is None: + region.set_style(StyleProperties.Origin, initial_origin if initial_origin is not None \ + else StyleProperties.Origin.make_initial_value()) + + # compute extent + if (region.get_style(StyleProperties.Extent)) is not None: + StyleProcessors.Extent.compute(None, region) + + if region.get_style(StyleProperties.Extent) is None: + region.set_style(StyleProperties.Extent, initial_extent if initial_extent is not None \ + else StyleProperties.Extent.make_initial_value() ) + + # computer writing_mode and display_align + + writing_mode = region.get_style(StyleProperties.WritingMode) + if writing_mode is None: + writing_mode = initial_writing_mode if initial_writing_mode is not None \ + else StyleProperties.WritingMode.make_initial_value() + + display_align = region.get_style(StyleProperties.DisplayAlign) + if display_align is None: + display_align = initial_display_align if initial_display_align is not None \ + else StyleProperties.DisplayAlign.make_initial_value() + + # determine new displayAlign value + new_display_align = DisplayAlignType.after + + if writing_mode in (WritingModeType.lrtb, WritingModeType.rltb): + if display_align == DisplayAlignType.before and region.get_style(StyleProperties.Origin).y.value < 50: + new_display_align = DisplayAlignType.before + elif region.get_style(StyleProperties.Origin).y.value + region.get_style(StyleProperties.Extent).height.value < 50: + new_display_align = DisplayAlignType.before + elif writing_mode == WritingModeType.tblr: + if display_align == DisplayAlignType.before and region.get_style(StyleProperties.Origin).x.value < 50: + new_display_align = DisplayAlignType.before + elif region.get_style(StyleProperties.Origin).x.value + region.get_style(StyleProperties.Extent).width.value < 50: + new_display_align = DisplayAlignType.before + else: # writing_mode == WritingModeType.tbrl + if display_align == DisplayAlignType.before and region.get_style(StyleProperties.Origin).x.value >= 50: + new_display_align = DisplayAlignType.before + elif region.get_style(StyleProperties.Origin).x.value + region.get_style(StyleProperties.Extent).width.value >= 50: + new_display_align = DisplayAlignType.before + + region.set_style(StyleProperties.DisplayAlign, new_display_align) + + # reposition region + region.set_style( + StyleProperties.Origin, + CoordinateType( + x=LengthType(self.config.safe_area, LengthType.Units.pct), + y=LengthType(self.config.safe_area, LengthType.Units.pct) + ) + ) + + region.set_style( + StyleProperties.Extent, + ExtentType( + height=LengthType(value=100 - 2 * self.config.safe_area, units=LengthType.Units.pct), + width=LengthType(value=100 - 2 * self.config.safe_area, units=LengthType.Units.pct) + ) + ) + + # check if a similar region has already been processed + + fingerprint = ( + region.get_begin() or 0, + region.get_end() or None, + writing_mode, + new_display_align + ) + + retained_region = retained_regions.get(fingerprint) + + if retained_region is None: + retained_regions[fingerprint] = region + else: + replaced_regions[region] = retained_region + + # prune aliased regions + if doc.get_body() is not None: + _replace_regions(doc.get_body(), replaced_regions) + + for region in list(doc.iter_regions()): + if region in replaced_regions: + doc.remove_region(region.get_id()) + + # apply background color + if self.config.bg_color is not None: + _apply_bg_color(doc.get_body(), self.config.bg_color) + + # apply text color + if doc.get_body() is not None and self.config.color is not None: + doc.get_body().set_style(StyleProperties.Color, self.config.color) + + # apply text align + if doc.get_body() is not None and not self.config.preserve_text_align: + doc.get_body().set_style(StyleProperties.TextAlign, TextAlignType.center) diff --git a/src/main/python/ttconv/filters/document_filter.py b/src/main/python/ttconv/filters/document_filter.py new file mode 100644 index 00000000..7dd002c1 --- /dev/null +++ b/src/main/python/ttconv/filters/document_filter.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2023, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Data model filter""" + +from __future__ import annotations +from typing import Optional + +from ttconv.model import ContentDocument +from ttconv.config import ModuleConfiguration + +class DocumentFilter: + """Abstract base class for content document filters""" + + _all_filters = dict() + + def __init__(self, config: ModuleConfiguration) -> None: + self.config = config + + def process(self, doc: ContentDocument): + """Processes the specified document in place.""" + raise NotImplementedError + + @classmethod + def get_config_class(cls) -> ModuleConfiguration: + """Returns the configuration class for the filter.""" + raise NotImplementedError + + @classmethod + def get_filter_by_name(cls, name) -> Optional[DocumentFilter]: + """Returns a list of all document filters""" + return DocumentFilter._all_filters.get(name) + + def __init_subclass__(cls): + DocumentFilter._all_filters[cls.get_config_class().name()] = cls + +from ttconv.filters.doc import * diff --git a/src/main/python/ttconv/filters/isd/__init__.py b/src/main/python/ttconv/filters/isd/__init__.py new file mode 100644 index 00000000..22a58522 --- /dev/null +++ b/src/main/python/ttconv/filters/isd/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2020, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Collects ISD filters""" + + + diff --git a/src/main/python/ttconv/filters/default_style_properties.py b/src/main/python/ttconv/filters/isd/default_style_properties.py similarity index 96% rename from src/main/python/ttconv/filters/default_style_properties.py rename to src/main/python/ttconv/filters/isd/default_style_properties.py index 9fa08f81..077b004e 100644 --- a/src/main/python/ttconv/filters/default_style_properties.py +++ b/src/main/python/ttconv/filters/isd/default_style_properties.py @@ -28,7 +28,7 @@ import logging from typing import Dict, Type, Any -from ttconv.filters import Filter +from ttconv.filters.isd_filter import ISDFilter from ttconv.isd import ISD from ttconv.model import ContentElement from ttconv.style_properties import StyleProperty @@ -36,7 +36,7 @@ LOGGER = logging.getLogger(__name__) -class DefaultStylePropertyValuesFilter(Filter): +class DefaultStylePropertyValuesISDFilter(ISDFilter): """Filter that remove default style properties""" def __init__(self, style_property_default_values: Dict[Type[StyleProperty], Any]): diff --git a/src/main/python/ttconv/filters/merge_paragraphs.py b/src/main/python/ttconv/filters/isd/merge_paragraphs.py similarity index 97% rename from src/main/python/ttconv/filters/merge_paragraphs.py rename to src/main/python/ttconv/filters/isd/merge_paragraphs.py index 66670ba9..d3717b7c 100644 --- a/src/main/python/ttconv/filters/merge_paragraphs.py +++ b/src/main/python/ttconv/filters/isd/merge_paragraphs.py @@ -27,14 +27,14 @@ import logging -from ttconv.filters import Filter +from ttconv.filters.isd_filter import ISDFilter from ttconv.isd import ISD from ttconv.model import Div, P, Br, ContentElement LOGGER = logging.getLogger(__name__) -class ParagraphsMergingFilter(Filter): +class ParagraphsMergingISDFilter(ISDFilter): """Filter for merging ISD document paragraphs per region into a single paragraph""" def _get_paragraphs(self, element: ContentElement): diff --git a/src/main/python/ttconv/filters/merge_regions.py b/src/main/python/ttconv/filters/isd/merge_regions.py similarity index 96% rename from src/main/python/ttconv/filters/merge_regions.py rename to src/main/python/ttconv/filters/isd/merge_regions.py index c3b450d2..fab59406 100644 --- a/src/main/python/ttconv/filters/merge_regions.py +++ b/src/main/python/ttconv/filters/isd/merge_regions.py @@ -27,14 +27,14 @@ import logging -from ttconv.filters import Filter +from ttconv.filters.isd_filter import ISDFilter from ttconv.isd import ISD from ttconv.model import Body LOGGER = logging.getLogger(__name__) -class RegionsMergingFilter(Filter): +class RegionsMergingISDFilter(ISDFilter): """Filter for merging ISD document regions into a single region""" def process(self, isd: ISD): diff --git a/src/main/python/ttconv/filters/isd/supported_style_properties.py b/src/main/python/ttconv/filters/isd/supported_style_properties.py new file mode 100644 index 00000000..f1983515 --- /dev/null +++ b/src/main/python/ttconv/filters/isd/supported_style_properties.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2020, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Filter for style properties supported by the output""" + +import logging +from typing import Dict, List, Type + +from ttconv.filters.isd_filter import ISDFilter +from ttconv.isd import ISD +from ttconv.model import ContentElement +from ttconv.style_properties import StyleProperty +import ttconv.filters.supported_style_properties + +LOGGER = logging.getLogger(__name__) + + +class SupportedStylePropertiesISDFilter(ISDFilter): + """Filter that remove unsupported style properties""" + + def __init__(self, supported_style_properties: Dict[Type[StyleProperty], List]): + self.filter = ttconv.filters.supported_style_properties.SupportedStylePropertiesFilter(supported_style_properties) + + def process(self, isd: ISD): + """Filter ISD document style properties""" + LOGGER.debug("Filter default style properties from ISD.") + + for region in isd.iter_regions(): + self.filter.process_element(region) diff --git a/src/main/python/ttconv/filters/isd_filter.py b/src/main/python/ttconv/filters/isd_filter.py new file mode 100644 index 00000000..75c99217 --- /dev/null +++ b/src/main/python/ttconv/filters/isd_filter.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2020, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Data model filter""" + +from ttconv.isd import ISD + +class ISDFilter: + """Abstract base class for filters""" + + def process(self, isd: ISD): + """Process the specified ISD and returns it.""" + raise NotImplementedError diff --git a/src/main/python/ttconv/filters/remove_animations.py b/src/main/python/ttconv/filters/remove_animations.py new file mode 100644 index 00000000..9691472c --- /dev/null +++ b/src/main/python/ttconv/filters/remove_animations.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2020, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Filter that remove animations""" + +import logging +from typing import Dict, List, Type + +from ttconv.model import ContentDocument, ContentElement +from ttconv.style_properties import StyleProperty + +class RemoveAnimationFilter: + """Filter that remove animations""" + + def __init__(self) -> None: + self._has_removed_animations = False + + def has_removed_animations(self) -> bool: + return self._has_removed_animations + + def process_element(self, element: ContentElement, recursive = True): + """Removes animations from content elements""" + + for step in element.iter_animation_steps(): + element.remove_animation_step(step) + self._has_removed_animations = True + + if recursive: + for child in element: + self.process_element(child) \ No newline at end of file diff --git a/src/main/python/ttconv/filters/supported_style_properties.py b/src/main/python/ttconv/filters/supported_style_properties.py index 8c1325b3..1ba8e90a 100644 --- a/src/main/python/ttconv/filters/supported_style_properties.py +++ b/src/main/python/ttconv/filters/supported_style_properties.py @@ -23,32 +23,38 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -"""Filter for style properties supported by the output""" +"""Filters style properties""" import logging from typing import Dict, List, Type -from ttconv.filters import Filter -from ttconv.isd import ISD -from ttconv.model import ContentElement +from ttconv.model import ContentDocument, ContentElement from ttconv.style_properties import StyleProperty -LOGGER = logging.getLogger(__name__) - - -class SupportedStylePropertiesFilter(Filter): - """Filter that remove unsupported style properties""" +class SupportedStylePropertiesFilter: + """Filter that removes unsupported style properties""" def __init__(self, supported_style_properties: Dict[Type[StyleProperty], List]): self.supported_style_properties = supported_style_properties - def _process_element(self, element: ContentElement): - """Filter ISD element style properties""" + def process_initial_values(self, doc: ContentDocument): + """Removes initial values that target unsupported style properties""" + for style_prop, value in list(doc.iter_initial_values()): + + if style_prop in self.supported_style_properties: + supported_values = self.supported_style_properties[style_prop] - element_styles = list(element.iter_styles()) - for style_prop in element_styles: + if len(supported_values) == 0 or value in supported_values: + continue - if style_prop in self.supported_style_properties.keys(): + doc.put_initial_value(style_prop, None) + + def process_element(self, element: ContentElement, recursive = True): + """Removes unsupported style properties from content elements""" + + for style_prop in list(element.iter_styles()): + + if style_prop in self.supported_style_properties: value = element.get_style(style_prop) supported_values = self.supported_style_properties[style_prop] @@ -57,12 +63,6 @@ def _process_element(self, element: ContentElement): element.set_style(style_prop, None) - for child in element: - self._process_element(child) - - def process(self, isd: ISD): - """Filter ISD document style properties""" - LOGGER.debug("Filter default style properties from ISD.") - - for region in isd.iter_regions(): - self._process_element(region) + if recursive: + for child in element: + self.process_element(child) \ No newline at end of file diff --git a/src/main/python/ttconv/imsc/style_properties.py b/src/main/python/ttconv/imsc/style_properties.py index 628610dd..d6045305 100644 --- a/src/main/python/ttconv/imsc/style_properties.py +++ b/src/main/python/ttconv/imsc/style_properties.py @@ -29,6 +29,7 @@ import math import typing import ttconv.imsc.namespaces as xml_ns +import ttconv.utils import ttconv.style_properties as styles import ttconv.imsc.utils as utils import ttconv.model as model @@ -95,7 +96,7 @@ class BackgroundColor(StyleProperty): @classmethod def extract(cls, context: StyleParsingContext, xml_attrib: str): - return utils.parse_color(xml_attrib) + return ttconv.utils.parse_color(xml_attrib) @classmethod def from_model(cls, xml_element, model_value: styles.ColorType): @@ -114,7 +115,7 @@ class Color(StyleProperty): @classmethod def extract(cls, context: StyleParsingContext, xml_attrib: str): - return utils.parse_color(xml_attrib) + return ttconv.utils.parse_color(xml_attrib) @classmethod def from_model(cls, xml_element, model_value): @@ -860,7 +861,7 @@ def extract(cls, context: StyleParsingContext, xml_attrib: str): else: - color = utils.parse_color(c) + color = ttconv.utils.parse_color(c) if style_style is None and style_symbol is None: @@ -924,7 +925,7 @@ def extract(cls, context: StyleParsingContext, xml_attrib: str) -> typing.Union[ thickness = StyleProperties.ttml_length_to_model(context, s[-1]) - color = utils.parse_color(s[0]) if len(s) == 2 else None + color = ttconv.utils.parse_color(s[0]) if len(s) == 2 else None return styles.TextOutlineType( color=color, @@ -1000,12 +1001,12 @@ def extract(cls, context: StyleParsingContext, xml_attrib: str) -> typing.Union[ except ValueError: - color = utils.parse_color(cs[2]) + color = ttconv.utils.parse_color(cs[2]) else: # len(cs) == 4 blur_radius = StyleProperties.ttml_length_to_model(context, cs[2]) - color = utils.parse_color(cs[3]) + color = ttconv.utils.parse_color(cs[3]) shadows.append( styles.TextShadowType.Shadow( diff --git a/src/main/python/ttconv/imsc/utils.py b/src/main/python/ttconv/imsc/utils.py index 0fe77875..61e28a55 100644 --- a/src/main/python/ttconv/imsc/utils.py +++ b/src/main/python/ttconv/imsc/utils.py @@ -30,11 +30,6 @@ from fractions import Fraction import ttconv.style_properties as styles - -_HEX_COLOR_RE = re.compile(r"#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})?") -_DEC_COLOR_RE = re.compile(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)") -_DEC_COLORA_RE = re.compile(r"rgba\(\s*(\d+),\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)") - _LENGTH_RE = re.compile(r"^((?:\+|\-)?\d*(?:\.\d+)?)(px|em|c|%|rh|rw)$") _CLOCK_TIME_FRACTION_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d(?:\.\d+)?)$") @@ -47,58 +42,6 @@ _OFFSET_M_RE = re.compile(r"^(\d+(?:\.\d+)?)m$") -def parse_color(attr_value: str) -> styles.ColorType: - '''Parses the TTML \\ value contained in `attr_value` - ''' - - lower_attr_value = str.lower(attr_value) - - if lower_attr_value in styles.NamedColors.__members__: - - return styles.NamedColors[lower_attr_value].value - - m = _HEX_COLOR_RE.match(attr_value) - - if m: - - return styles.ColorType( - ( - int(m.group(1), 16), - int(m.group(2), 16), - int(m.group(3), 16), - int(m.group(4), 16) if m.group(4) else 255 - ) - ) - - m = _DEC_COLOR_RE.match(attr_value) - - if m: - - return styles.ColorType( - ( - int(m.group(1)), - int(m.group(2)), - int(m.group(3)), - 255 - ) - ) - - m = _DEC_COLORA_RE.match(attr_value) - - if m: - - return styles.ColorType( - ( - int(m.group(1)), - int(m.group(2)), - int(m.group(3)), - int(m.group(4)) - ) - ) - - raise ValueError("Bad Syntax") - - def parse_length(attr_value: str) -> typing.Tuple[float, str]: '''Parses the TTML length in `attr_value` into a (length, units) tuple''' diff --git a/src/main/python/ttconv/srt/reader.py b/src/main/python/ttconv/srt/reader.py index a8ddfa42..177e93d8 100644 --- a/src/main/python/ttconv/srt/reader.py +++ b/src/main/python/ttconv/srt/reader.py @@ -35,7 +35,7 @@ from ttconv import model from ttconv import style_properties as styles -from ttconv.imsc.utils import parse_color +from ttconv.utils import parse_color LOGGER = logging.getLogger(__name__) diff --git a/src/main/python/ttconv/srt/writer.py b/src/main/python/ttconv/srt/writer.py index da854156..fd43ac17 100644 --- a/src/main/python/ttconv/srt/writer.py +++ b/src/main/python/ttconv/srt/writer.py @@ -31,11 +31,11 @@ import ttconv.model as model import ttconv.srt.style as style -from ttconv.filters import Filter -from ttconv.filters.default_style_properties import DefaultStylePropertyValuesFilter -from ttconv.filters.merge_paragraphs import ParagraphsMergingFilter -from ttconv.filters.merge_regions import RegionsMergingFilter -from ttconv.filters.supported_style_properties import SupportedStylePropertiesFilter +from ttconv.filters.isd_filter import ISDFilter +from ttconv.filters.isd.default_style_properties import DefaultStylePropertyValuesISDFilter +from ttconv.filters.isd.merge_paragraphs import ParagraphsMergingISDFilter +from ttconv.filters.isd.merge_regions import RegionsMergingISDFilter +from ttconv.filters.isd.supported_style_properties import SupportedStylePropertiesISDFilter from ttconv.isd import ISD from ttconv.srt.paragraph import SrtParagraph from ttconv.srt.config import SRTWriterConfiguration @@ -47,10 +47,10 @@ class SrtContext: """SRT writer context""" - filters: List[Filter] = ( - RegionsMergingFilter(), - ParagraphsMergingFilter(), - SupportedStylePropertiesFilter({ + filters: List[ISDFilter] = ( + RegionsMergingISDFilter(), + ParagraphsMergingISDFilter(), + SupportedStylePropertiesISDFilter({ StyleProperties.FontWeight: [ # Every values ], @@ -65,7 +65,7 @@ class SrtContext: # Every values ], }), - DefaultStylePropertyValuesFilter({ + DefaultStylePropertyValuesISDFilter({ StyleProperties.Color: NamedColors.white.value, StyleProperties.FontWeight: FontWeightType.normal, StyleProperties.FontStyle: FontStyleType.normal, diff --git a/src/main/python/ttconv/tt.py b/src/main/python/ttconv/tt.py index 5175ead9..d4def82f 100755 --- a/src/main/python/ttconv/tt.py +++ b/src/main/python/ttconv/tt.py @@ -34,6 +34,7 @@ from argparse import ArgumentParser from enum import Enum from pathlib import Path +from ttconv.filters.document_filter import DocumentFilter import ttconv.imsc.reader as imsc_reader import ttconv.imsc.writer as imsc_writer @@ -257,6 +258,7 @@ def decorator(func): argument("-o", "--output", help="Output file path", required=True), argument("--itype", help="Input file type", required=False), argument("--otype", help="Output file type", required=False), + argument("--filter", action="append", help="Document filter", required=False, default=[]), argument("--config", help="Configuration in json. Overridden by --config_file.", required=False), argument("--config_file", help="Configuration file. Overrides --config_file.", required=False) ]) @@ -362,6 +364,28 @@ def convert(args): if general_config is not None and general_config.document_lang is not None: model.set_lang(general_config.document_lang) + # + # apply document filter + # + + for filter_name in args.filter: + doc_filter_class = DocumentFilter.get_filter_by_name(filter_name) + + if doc_filter_class is None: + LOGGER.error("Unknown filter: %s", filter_name) + continue + + filter_config_class = doc_filter_class.get_config_class() + + filter_config = read_config_from_json(filter_config_class, json_config_data) + + doc_filter: DocumentFilter = doc_filter_class(filter_config or filter_config_class()) + + doc_filter.process(model) + + # + # Write the output document + # if writer_type is FileTypes.TTML: # # Read the config diff --git a/src/main/python/ttconv/utils.py b/src/main/python/ttconv/utils.py new file mode 100644 index 00000000..3402f0c8 --- /dev/null +++ b/src/main/python/ttconv/utils.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2023, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +'''Common utilities''' + +import re +import ttconv.style_properties as styles + +_HEX_COLOR_RE = re.compile(r"#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})?") +_DEC_COLOR_RE = re.compile(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)") +_DEC_COLORA_RE = re.compile(r"rgba\(\s*(\d+),\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)") + +def parse_color(attr_value: str) -> styles.ColorType: + '''Parses the TTML \\ value contained in `attr_value` + ''' + + lower_attr_value = str.lower(attr_value) + + if lower_attr_value in styles.NamedColors.__members__: + + return styles.NamedColors[lower_attr_value].value + + m = _HEX_COLOR_RE.match(attr_value) + + if m: + + return styles.ColorType( + ( + int(m.group(1), 16), + int(m.group(2), 16), + int(m.group(3), 16), + int(m.group(4), 16) if m.group(4) else 255 + ) + ) + + m = _DEC_COLOR_RE.match(attr_value) + + if m: + + return styles.ColorType( + ( + int(m.group(1)), + int(m.group(2)), + int(m.group(3)), + 255 + ) + ) + + m = _DEC_COLORA_RE.match(attr_value) + + if m: + + return styles.ColorType( + ( + int(m.group(1)), + int(m.group(2)), + int(m.group(3)), + int(m.group(4)) + ) + ) + + raise ValueError("Bad Syntax") \ No newline at end of file diff --git a/src/main/python/ttconv/vtt/writer.py b/src/main/python/ttconv/vtt/writer.py index 5959f7c1..2a079e61 100644 --- a/src/main/python/ttconv/vtt/writer.py +++ b/src/main/python/ttconv/vtt/writer.py @@ -32,10 +32,10 @@ import ttconv.model as model from ttconv.vtt.config import VTTWriterConfiguration import ttconv.vtt.style as style -from ttconv.filters.default_style_properties import DefaultStylePropertyValuesFilter -from ttconv.filters.merge_paragraphs import ParagraphsMergingFilter -from ttconv.filters.merge_regions import RegionsMergingFilter -from ttconv.filters.supported_style_properties import SupportedStylePropertiesFilter +from ttconv.filters.isd.default_style_properties import DefaultStylePropertyValuesISDFilter +from ttconv.filters.isd.merge_paragraphs import ParagraphsMergingISDFilter +from ttconv.filters.isd.merge_regions import RegionsMergingISDFilter +from ttconv.filters.isd.supported_style_properties import SupportedStylePropertiesISDFilter from ttconv.isd import ISD from ttconv.vtt.cue import VttCue from ttconv.vtt.css_class import CssClass @@ -61,9 +61,9 @@ def __init__(self, config: VTTWriterConfiguration): self._filters = [] if not self._config.line_position: - self._filters.append(RegionsMergingFilter()) + self._filters.append(RegionsMergingISDFilter()) - self._filters.append(ParagraphsMergingFilter()) + self._filters.append(ParagraphsMergingISDFilter()) supported_styles = { StyleProperties.FontWeight: [], @@ -92,10 +92,10 @@ def __init__(self, config: VTTWriterConfiguration): StyleProperties.Direction: [], }) - self._filters.append(SupportedStylePropertiesFilter(supported_styles)) + self._filters.append(SupportedStylePropertiesISDFilter(supported_styles)) self._filters.append( - DefaultStylePropertyValuesFilter({ + DefaultStylePropertyValuesISDFilter({ StyleProperties.Color: NamedColors.white.value, StyleProperties.BackgroundColor: NamedColors.transparent.value, StyleProperties.FontWeight: FontWeightType.normal, diff --git a/src/test/python/test_config.py b/src/test/python/test_config.py index 0714e45e..223c9470 100644 --- a/src/test/python/test_config.py +++ b/src/test/python/test_config.py @@ -125,6 +125,10 @@ def test_default_config_parsing(self): for exp_config in expected_configurations: self.assertTrue(exp_config in module_configurations) + def test_empty_config_parsing(self): + + config_dict = json.loads('{}') + if __name__ == '__main__': unittest.main() diff --git a/src/test/python/test_filter_default_style_properties.py b/src/test/python/test_filter_default_style_properties.py index cf645b4a..77c92651 100644 --- a/src/test/python/test_filter_default_style_properties.py +++ b/src/test/python/test_filter_default_style_properties.py @@ -29,7 +29,7 @@ from fractions import Fraction import unittest -from ttconv.filters.default_style_properties import DefaultStylePropertyValuesFilter +from ttconv.filters.isd.default_style_properties import DefaultStylePropertyValuesISDFilter from ttconv.isd import ISD from ttconv.model import P, ContentDocument, Region, Body, Div, Span, Text from ttconv.style_properties import StyleProperties, NamedColors, FontStyleType, DirectionType @@ -38,7 +38,7 @@ class DefaultStylesFilterTest(unittest.TestCase): def test_process_element(self): - default_style_value_filter = DefaultStylePropertyValuesFilter({ + default_style_value_filter = DefaultStylePropertyValuesISDFilter({ StyleProperties.Color: StyleProperties.Color.make_initial_value() }) @@ -55,7 +55,7 @@ def test_process_element(self): self.assertEqual(len(StyleProperties.ALL) - 1, len(p._styles)) def test_process_isd(self): - default_style_value_filter = DefaultStylePropertyValuesFilter({ + default_style_value_filter = DefaultStylePropertyValuesISDFilter({ StyleProperties.BackgroundColor: NamedColors.red.value, StyleProperties.Direction: DirectionType.ltr }) diff --git a/src/test/python/test_filter_merge_paragraphs.py b/src/test/python/test_filter_merge_paragraphs.py index 81eb8c9e..fb15ec59 100644 --- a/src/test/python/test_filter_merge_paragraphs.py +++ b/src/test/python/test_filter_merge_paragraphs.py @@ -30,7 +30,7 @@ from typing import List import unittest -from ttconv.filters.merge_paragraphs import ParagraphsMergingFilter +from ttconv.filters.isd.merge_paragraphs import ParagraphsMergingISDFilter from ttconv.isd import ISD from ttconv.model import P, Body, Div, Span, Text, ContentElement, Br @@ -66,7 +66,7 @@ def _get_text_from_children(element: ContentElement) -> str: return ParagraphsMergingFilterTest._get_text_from_children(child) def test_merging_regions(self): - paragraphs_merging_filter = ParagraphsMergingFilter() + paragraphs_merging_filter = ParagraphsMergingISDFilter() isd = ISD(None) diff --git a/src/test/python/test_filter_merge_regions.py b/src/test/python/test_filter_merge_regions.py index ba29d9de..03d4e951 100644 --- a/src/test/python/test_filter_merge_regions.py +++ b/src/test/python/test_filter_merge_regions.py @@ -29,7 +29,7 @@ import unittest -from ttconv.filters.merge_regions import RegionsMergingFilter +from ttconv.filters.isd.merge_regions import RegionsMergingISDFilter from ttconv.isd import ISD from ttconv.model import P, Body, Div, Span, Text, ContentElement @@ -62,7 +62,7 @@ def _get_text_from_children(element: ContentElement) -> str: return RegionsMergingFilterTest._get_text_from_children(child) def test_merging_regions(self): - regions_merging_filter = RegionsMergingFilter() + regions_merging_filter = RegionsMergingISDFilter() isd = ISD(None) diff --git a/src/test/python/test_filter_supported_style_properties.py b/src/test/python/test_filter_supported_style_properties.py index 6477fd71..bfbc0720 100644 --- a/src/test/python/test_filter_supported_style_properties.py +++ b/src/test/python/test_filter_supported_style_properties.py @@ -30,6 +30,7 @@ import unittest from ttconv.filters.supported_style_properties import SupportedStylePropertiesFilter +from ttconv.filters.isd.supported_style_properties import SupportedStylePropertiesISDFilter as SupportedStylePropertiesFilterISD from ttconv.isd import ISD from ttconv.model import P, ContentDocument, Region, Body, Div, Span, Text from ttconv.style_properties import StyleProperties, NamedColors, FontStyleType, DirectionType, ExtentType, LengthType @@ -53,7 +54,7 @@ def test_process_element(self): self.assertEqual(StyleProperties.Color.make_initial_value(), p.get_style(StyleProperties.Color)) self.assertEqual(StyleProperties.Extent.make_initial_value(), p.get_style(StyleProperties.Extent)) - supported_style_properties._process_element(p) + supported_style_properties.process_element(p) self.assertIsNone(p.get_style(StyleProperties.Color)) self.assertEqual(StyleProperties.Extent.make_initial_value(), p.get_style(StyleProperties.Extent)) @@ -61,14 +62,14 @@ def test_process_element(self): p.set_style(StyleProperties.Color, NamedColors.red.value) - supported_style_properties._process_element(p) + supported_style_properties.process_element(p) self.assertEqual(2, len(p._styles)) self.assertEqual(NamedColors.red.value, p.get_style(StyleProperties.Color)) self.assertEqual(StyleProperties.Extent.make_initial_value(), p.get_style(StyleProperties.Extent)) def test_process_isd(self): - supported_style_properties = SupportedStylePropertiesFilter({ + supported_style_properties = SupportedStylePropertiesFilterISD({ StyleProperties.BackgroundColor: [ NamedColors.red.value ], diff --git a/src/test/python/test_imsc_color_parser.py b/src/test/python/test_imsc_color_parser.py index f0a5942e..da0225a9 100644 --- a/src/test/python/test_imsc_color_parser.py +++ b/src/test/python/test_imsc_color_parser.py @@ -28,7 +28,7 @@ # pylint: disable=R0201,C0115,C0116 import unittest -from ttconv.imsc.utils import parse_color +from ttconv.utils import parse_color from ttconv.style_properties import ColorType class IMSCReaderTest(unittest.TestCase): diff --git a/src/test/python/test_lcd_filter.py b/src/test/python/test_lcd_filter.py new file mode 100644 index 00000000..06597674 --- /dev/null +++ b/src/test/python/test_lcd_filter.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +# Copyright (c) 2023, Sandflow Consulting LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +'''Unit tests for lcd document filter''' + +# pylint: disable=R0201,C0115,C0116 + +import unittest +from ttconv.filters.doc.lcd import LCDDocFilter, LCDDocFilterConfig +import ttconv.model as model +import ttconv.style_properties as styles + +class LCDFilterTests(unittest.TestCase): + + ''' + + + + + + + +
+ +
+ + ''' + + def test_region_merging(self): + doc = model.ContentDocument() + + # r1 + r1 = model.Region("r1", doc) + doc.put_region(r1) + + # r2: tts:extent="100% 100%" + r2 = model.Region("r2", doc) + r2.set_style( + styles.StyleProperties.Extent, + styles.ExtentType( + height=styles.LengthType(value=100), + width=styles.LengthType(value=100) + ) + ) + doc.put_region(r2) + + # r3: tts:displayAlign="after" + r3 = model.Region("r3", doc) + r3.set_style( + styles.StyleProperties.DisplayAlign, + styles.DisplayAlignType.after + ) + doc.put_region(r3) + + # r4: set: tts:displayAlign="after" + r4 = model.Region("r4", doc) + r4.add_animation_step( + model.DiscreteAnimationStep( + style_property=styles.StyleProperties.DisplayAlign, + begin=None, + end=None, + value=styles.DisplayAlignType.after + ) + ) + doc.put_region(r4) + + # r5: begin="1s" + r5 = model.Region("r5", doc) + r5.set_begin(1) + doc.put_region(r5) + + # body + body = model.Body(doc) + doc.set_body(body) + + # div + div = model.Div(doc) + body.push_child(div) + + # p1: r1 + p1 = model.P(doc) + p1.set_id("p1") + p1.set_region(r1) + div.push_child(p1) + + # p2: r2 + p2 = model.P(doc) + p2.set_id("p2") + p2.set_region(r2) + div.push_child(p2) + + # p3: r3 + p3 = model.P(doc) + p3.set_id("p3") + p3.set_region(r3) + div.push_child(p3) + + # p4: r4 + p4 = model.P(doc) + p4.set_id("p4") + p4.set_region(r4) + div.push_child(p4) + + # p5: r5 + p5 = model.P(doc) + p5.set_id("p5") + p5.set_region(r5) + div.push_child(p5) + + filter = LCDDocFilter(LCDDocFilterConfig()) + + self.assertIsNone(filter.process(doc)) + + self.assertSetEqual( + set(["r1", "r3", "r5"]), + set([r.get_id() for r in doc.iter_regions()]) + ) + + def test_region_resizing(self): + doc = model.ContentDocument() + + # r1: origin=10,10 extent=80,20 + r1 = model.Region("r1", doc) + r1.set_style( + styles.StyleProperties.Origin, + styles.CoordinateType( + x=styles.LengthType(value=10), + y=styles.LengthType(value=10) + ) + ) + r1.set_style( + styles.StyleProperties.Extent, + styles.ExtentType( + height=styles.LengthType(value=20), + width=styles.LengthType(value=80) + ) + ) + doc.put_region(r1) + + # r2: origin=10,70 extent=80,20 + r2 = model.Region("r2", doc) + r2.set_style( + styles.StyleProperties.Origin, + styles.CoordinateType( + x=styles.LengthType(value=10), + y=styles.LengthType(value=70) + ) + ) + r2.set_style( + styles.StyleProperties.Extent, + styles.ExtentType( + height=styles.LengthType(value=20), + width=styles.LengthType(value=80) + ) + ) + doc.put_region(r2) + + # r3: origin=10,10 extent=80,20 displayAlign=after + r3 = model.Region("r3", doc) + r3.set_style( + styles.StyleProperties.Origin, + styles.CoordinateType( + x=styles.LengthType(value=10), + y=styles.LengthType(value=10) + ) + ) + r3.set_style( + styles.StyleProperties.Extent, + styles.ExtentType( + height=styles.LengthType(value=20), + width=styles.LengthType(value=80) + ) + ) + r3.set_style( + styles.StyleProperties.DisplayAlign, + styles.DisplayAlignType.after + ) + doc.put_region(r3) + + # r4: origin=10,70 extent=80,20 displayAlign=after + r4 = model.Region("r4", doc) + r4.set_style( + styles.StyleProperties.Origin, + styles.CoordinateType( + x=styles.LengthType(value=10), + y=styles.LengthType(value=70) + ) + ) + r4.set_style( + styles.StyleProperties.Extent, + styles.ExtentType( + height=styles.LengthType(value=20), + width=styles.LengthType(value=80) + ) + ) + r4.set_style( + styles.StyleProperties.DisplayAlign, + styles.DisplayAlignType.after + ) + doc.put_region(r4) + + # body + body = model.Body(doc) + doc.set_body(body) + + # div + div = model.Div(doc) + body.push_child(div) + + # p1: r1 + p1 = model.P(doc) + p1.set_id("p1") + p1.set_region(r1) + div.push_child(p1) + + # p2: r2 + p2 = model.P(doc) + p2.set_id("p2") + p2.set_region(r2) + div.push_child(p2) + + # p3: r3 + p3 = model.P(doc) + p3.set_id("p3") + p3.set_region(r3) + div.push_child(p3) + + # p4: r4 + p4 = model.P(doc) + p4.set_id("p4") + p4.set_region(r4) + div.push_child(p4) + + # apply filter + filter = LCDDocFilter(LCDDocFilterConfig()) + + self.assertIsNone(filter.process(doc)) + + self.assertSetEqual( + set(["r1", "r2"]), + set([r.get_id() for r in doc.iter_regions()]) + ) + + self.assertLessEqual( + doc.get_region("r1").get_style(styles.StyleProperties.Origin).y.value, + 50 + ) + + self.assertGreaterEqual( + doc.get_region("r1").get_style(styles.StyleProperties.Extent).height.value, + 50 + ) + + self.assertEqual( + doc.get_region("r1").get_style(styles.StyleProperties.DisplayAlign), + styles.DisplayAlignType.before + ) + + self.assertLessEqual( + doc.get_region("r2").get_style(styles.StyleProperties.Origin).y.value, + 50 + ) + + self.assertGreaterEqual( + doc.get_region("r2").get_style(styles.StyleProperties.Extent).height.value, + 50 + ) + + self.assertEqual( + doc.get_region("r2").get_style(styles.StyleProperties.DisplayAlign), + styles.DisplayAlignType.after + ) + + def test_text_align(self): + doc = model.ContentDocument() + + # r1: origin=10,10 extent=80,20 + r1 = model.Region("r1", doc) + doc.put_region(r1) + + # body + body = model.Body(doc) + doc.set_body(body) + + # div + div = model.Div(doc) + body.push_child(div) + + # p1: r1 + p1 = model.P(doc) + p1.set_id("p1") + p1.set_region(r1) + p1.set_style(styles.StyleProperties.TextAlign, styles.TextAlignType.end) + div.push_child(p1) + + # apply filter + filter = LCDDocFilter(LCDDocFilterConfig()) + + self.assertIsNone(filter.process(doc)) + + self.assertIsNone(p1.get_style(styles.StyleProperties.TextAlign)) + self.assertEqual(body.get_style(styles.StyleProperties.TextAlign), styles.TextAlignType.center) + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/python/test_tt.py b/src/test/python/test_tt.py index d027c4b4..f9cd3c36 100644 --- a/src/test/python/test_tt.py +++ b/src/test/python/test_tt.py @@ -213,5 +213,23 @@ def test_document_lang_override(self): with open(out_path, encoding="utf-8") as f: self.assertRegex(f.read(), "lang=['\"]es-419['\"]") + def test_lcd_filter(self): + out_path = "build/referential_styling.ttml" + in_path = "src/test/resources/ttml/referential_styling.ttml" + + tt.main(['convert', + '-i', in_path, + '-o', out_path, + '--filter', 'lcd', + '--config', '{"lcd": {"bg_color": "blue", "safe_area": 0, "color": "red", "preserve_text_align": true}}' + ]) + + tt.main(['convert', + '-i', in_path, + '-o', out_path, + '--filter', 'lcd', + '--config', '{"lcd": {"bg_color":"red"}}' + ]) + if __name__ == '__main__': unittest.main()