Repository: sandflow/ttconv (language: python)

Issue:
STL reader: option to set a generic, basic style
The EBU mapping is not always welcome. For simple distribution to end users, a more generic/simplified format appears to be sometimes a better choice.
The first patch disables the "ebu style".
The second disables the vertical positioning in favor of a fixed bottom position, with an adjustable margin.

See https://github.com/sandflow/ttconv/pull/416#issue-2002605938


Discussion / hints:

Fix patch:
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 <input .scc file> -o <output .ttml file>
 
 * `--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" : <integer between 0 and 30>`
+
+Specifies the safe area (as a percentage of the height and width of the root container)
+
+Default: `10`
+
+#### color
+
+`"color" : <TTML color> | null`
+
+If not `null`, overrides text color. The syntax of `TTML color` is
+specified at <https://www.w3.org/TR/ttml2/#style-value-color>.
+
+Default: `null`
+
+Examples: `"#FFFFFF"` (white), `"white"`
+
+#### bg_color
+
+`"bg_color" : <TTML color>`
+
+If not `null`, overrides the background color. The syntax of `TTML color` is
+specified at <https://www.w3.org/TR/ttml2/#style-value-color>.
+
+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 \\<color\\> 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 \\<color\\> 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,


Test patch:
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):
+
+  '''
+  <region xml:id="r1"/>
+  <region xml:id="r2"/>
+  <region xml:id="r2" begin="2s" end="9s">
+    <set tts:color="red"/>
+  </region>
+
+  <body>
+    <div>
+
+    </div>
+  </body>
+  '''
+
+  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()