diff --git a/docs/changelog.rst b/docs/changelog.rst
index a2f5de41..ff2013d9 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -22,6 +22,14 @@ Changelog
- Split pycaption/dfxp/ into constants.py, reader.py, writer.py
(same pattern as scc/ and webvtt/). Public API unchanged —
``__init__.py`` re-exports all symbols.
+ - SAMI writer: align with WebVTT reader's structured output — emit
+ ``classes`` as ``class=`` attribute (not inline CSS), emit text-align
+ from Layout, produce valid CSS in stylesheet for VTT class styles,
+ filter non-CSS keys (webvtt_positioning, writing_direction), silently
+ drop writing direction (SAMI has no writing-mode).
+ - Split pycaption/sami.py into pycaption/sami/ package. Public API
+ unchanged. Reader: inline dead hooks, extract helpers, add docstrings.
+ Writer: fix rules-dict mutation, remove dead ``_encode``, staticmethods.
2.2.28
^^^^^^
diff --git a/pycaption/sami.py b/pycaption/sami.py
deleted file mode 100644
index 078acb71..00000000
--- a/pycaption/sami.py
+++ /dev/null
@@ -1,809 +0,0 @@
-"""
-The classes in this module handle SAMI reading and writing. It supports several
-CSS attributes, some of which are handled as positioning settings (and applied
-to Layout objects) and others as simple styling (applied to legacy style nodes).
-
-The following attributes are handled as positioning:
-
- 'text-align' # Converted to Alignment
- 'margin-top'
- 'margin-right'
- 'margin-bottom'
- 'margin-left'
-
-OBS:
- * Margins are converted to Padding
- * Margins defined inline are not supported
- TODO: Add support for inline margins
-
-Any other CSS the BeautifulSoup library manages to parse is handled as simple
-styling and applied to style nodes. However, apparently only these are actually
-used by writers on conversion:
-
- 'font-family'
- 'font-size'
- 'font-style'
- 'color'
-OBS:
- * Other parameters are preserved, but not if they're specified inline.
- TODO:
- Make this less confusing. Confirm whether these really are the only
- supported styling attributes and make it more clear, perhaps by listing
- them in constants in the beginning of the file and using them to filter
- out unneeded attributes either everywhere in the code or not at all, but
- most importantly regardless of whether they're defined inline or not,
- because this is irrelevant.
-
-"""
-import re
-from collections import deque
-from copy import deepcopy
-from html.entities import name2codepoint
-from html.parser import HTMLParser
-from logging import FATAL
-from xml.dom import SyntaxErr
-from xml.sax.saxutils import escape
-
-from bs4 import BeautifulSoup, NavigableString
-from cssutils import css as cssutils_css
-from cssutils import log, parseString
-
-from .base import (
- DEFAULT_LANGUAGE_CODE,
- BaseReader,
- BaseWriter,
- Caption,
- CaptionList,
- CaptionNode,
- CaptionSet,
-)
-from .exceptions import (
- CaptionReadNoCaptions,
- CaptionReadSyntaxError,
- CaptionReadTimingError,
- InvalidInputError,
-)
-from .geometry import Alignment, Layout, Padding, Size
-
-# change cssutils default logging
-log.setLevel(FATAL)
-
-SAMI_BASE_MARKUP = """
-
\n "
- elif node.type_ == CaptionNode.STYLE:
- line = self._recreate_line_style(line, node)
-
- return line.rstrip()
-
- def _recreate_line_style(self, line, node):
- if node.start:
- if self.open_span:
- line = line.rstrip() + " "
- line = self._recreate_span(line, node.content)
- else:
- if self.open_span:
- line = line.rstrip() + " "
- self.open_span = False
-
- return line
-
- def _recreate_span(self, line, content):
- style = ""
- klass = ""
- if "class" in content:
- klass += f' class="{content["class"]}"'
-
- for attr, value in list(self._recreate_style(content).items()):
- style += f"{attr}:{value};"
-
- if style or klass:
- if style:
- style = f' style="{style}"'
- line += f""
- self.open_span = True
-
- return line
-
- def _recreate_style(self, rules):
- """
- :param rules: A dictionary with CSS-like styling rules
- """
- sami_style = {}
-
- for key, value in list(rules.items()):
- # Recreate original CSS rules from internal style
- if key == "italics" and value is True:
- sami_style["font-style"] = "italic"
- elif key == "bold" and value is True:
- sami_style["font-weight"] = "bold"
- elif key == "underline" and value is True:
- sami_style["text-decoration"] = "underline"
- else:
- sami_style[key] = value
-
- return sami_style
-
- def _encode(self, s):
- """
- Encodes plain unicode string to proper SAMI file escaping special
- characters in case they appear in the string.
- :type s: str
- """
- return escape(s)
-
-
-class SAMIParser(HTMLParser):
- def __init__(self, *args, **kw):
- HTMLParser.__init__(self, *args, **kw)
- self.sami = ""
- self.line = ""
- self.styles = {}
- self.queue = deque()
- self.langs = set()
- self.last_element = ""
- self.name2codepoint = name2codepoint.copy()
- self.name2codepoint["apos"] = 0x0027
- self.convert_charrefs = False
-
- def handle_starttag(self, tag, attrs):
- """
- Override the parser's handling of starttags
- :param tag: unicode string indicating the tag type (e.g. "head" or "p")
- :param tag: list of attribute tuples of type ('name', 'value')
- """
- self.last_element = tag
-
- # treat divs as spans
- if tag == "div":
- tag = "span"
-
- # figure out the caption language of P tags
- if tag == "p":
- lang = self._find_lang(attrs)
-
- # if no language detected, set it as the default
- lang = lang or DEFAULT_LANGUAGE_CODE
- attrs.append(("lang", lang))
- self.langs.add(lang)
-
- # clean-up line breaks
- if tag == "br":
- self.sami += "
"
- # add tag to queue
- else:
- # if already in queue, first close tags off in LIFO order
- while tag in self.queue:
- closer = self.queue.pop()
- self.sami += f"{closer}>"
- # open new tag in queue
- self.queue.append(tag)
- # add tag with attributes
- for attr, value in attrs:
- tag += f' {attr.lower()}="{value}"'
- self.sami += f"<{tag}>"
-
- # override the parser's handling of endtags
- def handle_endtag(self, tag):
- # treat divs as spans
- if tag == "div":
- tag = "span"
-
- # handle incorrectly formatted sync/p tags
- if tag in ["p", "sync"] and tag == self.last_element:
- return
-
- # close off tags in LIFO order, if matching starting tag in queue
- while tag in self.queue:
- closing_tag = self.queue.pop()
- self.sami += f"{closing_tag}>"
-
- def handle_entityref(self, name):
- if name in ["gt", "lt"]:
- self.sami += f"&{name};"
- else:
- try:
- self.sami += chr(self.name2codepoint[name])
- except (KeyError, ValueError):
- self.sami += f"&{name}"
-
- self.last_element = ""
-
- def handle_charref(self, name):
- if name[0] == "x":
- self.sami += chr(int(name[1:], 16))
- else:
- self.sami += chr(int(name))
-
- # override the parser's handling of data
- def handle_data(self, data):
- self.sami += data
- self.last_element = ""
-
- # override the parser's feed function
- def feed(self, data):
- """
- :param data: Raw SAMI unicode string
- :returns: tuple (str, dict, set)
- """
- no_cc = "no closed captioning available"
-
- if "")
- style = BeautifulSoup(data[:index], "lxml").find("style")
- if style and style.contents:
- self.styles = self._css_parse(" ".join(style.contents))
- else:
- self.styles = {}
-
- # fix erroneous italics tags
- data = data.replace("", "")
-
- # fix awkward tags found in some SAMIs
- data = data.replace(";>", ">")
- HTMLParser.feed(self, data)
-
- # close any tags that remain in the queue
- while self.queue != deque([]):
- closing_tag = self.queue.pop()
- self.sami += f"{closing_tag}>"
-
- return self.sami, self.styles, self.langs
-
- # parse the SAMI's stylesheet
- def _css_parse(self, css):
- """
- Parse styling via cssutils modules
- :rtype: dict
- """
- sheet = parseString(css)
- style_sheet = {}
-
- for rule in sheet:
- new_style = {}
- selector = rule.selectorText.lower()
- if selector[0] in ["#", "."]:
- selector = selector[1:]
- # keep any style attributes that are needed
- for prop in rule.style:
- if prop.name == "color":
- try:
- cv = cssutils_css.ColorValue(prop.value)
- except SyntaxErr:
- raise CaptionReadSyntaxError(
- f"Invalid color value: {prop.value}. Check for "
- f"missing # before hex values or misspelled color "
- f"values."
- )
- # Code for RGB to hex conversion comes from
- # http://bit.ly/1kwfBnQ
- new_style["color"] = f"#{cv.red:02x}{cv.green:02x}" f"{cv.blue:02x}"
- else:
- new_style[prop.name] = prop.value
- if new_style:
- style_sheet[selector] = new_style
-
- return style_sheet
-
- def _find_lang(self, attrs):
- for attr, value in attrs:
- # if lang is an attribute of the tag
- if attr.lower() == "lang":
- return value[:2]
- # if the P tag has a class, try and find the language
- if attr.lower() == "class":
- try:
- return self.styles[value.lower()]["lang"]
- except KeyError:
- pass
-
- return None
diff --git a/pycaption/sami/__init__.py b/pycaption/sami/__init__.py
new file mode 100644
index 00000000..5a09eefb
--- /dev/null
+++ b/pycaption/sami/__init__.py
@@ -0,0 +1,4 @@
+from .constants import SAMI_BASE_MARKUP # noqa: F401
+from .parser import SAMIParser # noqa: F401
+from .reader import SAMIReader # noqa: F401
+from .writer import SAMIWriter # noqa: F401
diff --git a/pycaption/sami/constants.py b/pycaption/sami/constants.py
new file mode 100644
index 00000000..38aecab0
--- /dev/null
+++ b/pycaption/sami/constants.py
@@ -0,0 +1,23 @@
+from logging import FATAL
+
+from cssutils import log
+
+from ..geometry import HorizontalAlignmentEnum
+
+log.setLevel(FATAL)
+
+SAMI_BASE_MARKUP = """
+
."""
+ self.last_element = tag
+
+ if tag == "div":
+ tag = "span"
+
+ if tag == "p":
+ lang = self._find_lang(attrs)
+ lang = lang or DEFAULT_LANGUAGE_CODE
+ attrs.append(("lang", lang))
+ self.langs.add(lang)
+
+ if tag == "br":
+ self.sami += "
"
+ else:
+ # Close any already-open instance of this tag (LIFO) before re-opening
+ while tag in self.queue:
+ closer = self.queue.pop()
+ self.sami += f"{closer}>"
+ self.queue.append(tag)
+ for attr, value in attrs:
+ tag += f' {attr.lower()}="{value}"'
+ self.sami += f"<{tag}>"
+
+ def handle_endtag(self, tag):
+ """Close tags in LIFO order; handle SAMI's malformed sync/p nesting."""
+ if tag == "div":
+ tag = "span"
+
+ # Skip duplicate close when the tag was just opened with no content
+ if tag in ["p", "sync"] and tag == self.last_element:
+ return
+
+ while tag in self.queue:
+ closing_tag = self.queue.pop()
+ self.sami += f"{closing_tag}>"
+
+ def handle_entityref(self, name):
+ """Convert named HTML entities to characters; preserve > and <."""
+ if name in ["gt", "lt"]:
+ self.sami += f"&{name};"
+ else:
+ try:
+ self.sami += chr(self.name2codepoint[name])
+ except (KeyError, ValueError):
+ self.sami += f"&{name}"
+
+ self.last_element = ""
+
+ def handle_charref(self, name):
+ """Convert numeric character references (NNN; or HHH;) to characters."""
+ self.sami += chr(int(name[1:], 16) if name[0] == "x" else int(name))
+
+ def handle_data(self, data):
+ """Append raw text content to the output."""
+ self.sami += data
+ self.last_element = ""
+
+ def feed(self, data):
+ """Parse raw SAMI content and return normalized XML, styles, and languages.
+
+ :param data: Raw SAMI unicode string
+ :returns: (normalized_xml, styles_dict, languages_set)
+ :rtype: tuple[str, dict, set]
+ :raises CaptionReadSyntaxError: if the file is HTML or has no captions
+ """
+ no_cc = "no closed captioning available"
+
+ if "")
+ style = BeautifulSoup(data[:index], "lxml").find("style")
+ if style and style.contents:
+ self.styles = self._css_parse(" ".join(style.contents))
+ else:
+ self.styles = {}
+
+ # Fix common SAMI authoring errors
+ data = data.replace("", "")
+ data = data.replace(";>", ">")
+ HTMLParser.feed(self, data)
+
+ # Close any tags left open at end of document
+ while self.queue:
+ closing_tag = self.queue.pop()
+ self.sami += f"{closing_tag}>"
+
+ return self.sami, self.styles, self.langs
+
+ @staticmethod
+ def _css_parse(css):
+ """Parse a SAMI STYLE block into a dict of selector → properties.
+
+ Color values are normalized to 6-digit hex (#rrggbb).
+
+ :type css: str
+ :rtype: dict[str, dict[str, str]]
+ """
+ sheet = parseString(css)
+ style_sheet = {}
+
+ for rule in sheet:
+ new_style = {}
+ selector = rule.selectorText.lower()
+ if selector[0] in ["#", "."]:
+ selector = selector[1:]
+ for prop in rule.style:
+ if prop.name == "color":
+ try:
+ cv = cssutils_css.ColorValue(prop.value)
+ except SyntaxErr:
+ raise CaptionReadSyntaxError(
+ f"Invalid color value: {prop.value}. Check for "
+ f"missing # before hex values or misspelled color "
+ f"values."
+ )
+ new_style["color"] = f"#{cv.red:02x}{cv.green:02x}{cv.blue:02x}"
+ else:
+ new_style[prop.name] = prop.value
+ if new_style:
+ style_sheet[selector] = new_style
+
+ return style_sheet
+
+ def _find_lang(self, attrs):
+ """Detect caption language from a
tag's attributes.
+
+ Checks for an explicit lang attribute first, then looks up the
+ class name in the parsed stylesheet for a lang property.
+
+ :param attrs: list of (name, value) attribute tuples
+ :rtype: str | None
+ """
+ for attr, value in attrs:
+ if attr.lower() == "lang":
+ return value[:2]
+ if attr.lower() == "class":
+ try:
+ return self.styles[value.lower()]["lang"]
+ except KeyError:
+ pass
+
+ return None
diff --git a/pycaption/sami/reader.py b/pycaption/sami/reader.py
new file mode 100644
index 00000000..77f9d698
--- /dev/null
+++ b/pycaption/sami/reader.py
@@ -0,0 +1,314 @@
+from bs4 import BeautifulSoup, NavigableString
+
+from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet
+from ..exceptions import (
+ CaptionReadNoCaptions,
+ CaptionReadTimingError,
+ InvalidInputError,
+)
+from ..geometry import Alignment, Layout, Padding, Size
+from .parser import SAMIParser
+
+_TAG_TO_STYLE = {"i": "italics", "b": "bold", "u": "underline"}
+
+
+class SAMIReader(BaseReader):
+ """Reads SAMI caption files into a CaptionSet.
+
+ Supports CSS-based styling (font, color, text-decoration), margin-based
+ positioning converted to Layout objects, and multi-language documents.
+ """
+
+ def __init__(self, *args, **kw):
+ super().__init__(*args, **kw)
+ self.line = []
+ self.first_alignment = None
+
+ def detect(self, content):
+ """Return True if content looks like a SAMI document."""
+ return " element into a Caption object.
+
+ :rtype: Caption
+ """
+ self.first_alignment = None
+ styles = self._translate_attrs(p)
+ layout_info = self._build_layout(styles, inherit_from=parent_layout)
+ self.line = []
+
+ self._translate_tag(p, layout_info)
+ caption_layout = Layout(
+ alignment=self.first_alignment, inherit_from=layout_info
+ )
+ for node in self.line:
+ node.layout_info = Layout(
+ alignment=self.first_alignment, inherit_from=node.layout_info
+ )
+ self.first_alignment = None
+
+ return Caption(start, 0, self.line, styles, caption_layout)
+
+ def _translate_tag(self, tag, inherit_from=None):
+ """Recursively convert a BeautifulSoup element into CaptionNodes.
+
+ Handles NavigableString (text), block for a single caption with styling and alignment."""
+ time = caption.start // 1000
+
+ if self.last_time and time != self.last_time:
+ sami = self._recreate_blank_tag(sami, caption, lang, primary, captions)
+
+ self.last_time = caption.end // 1000
+
+ sami, sync = self._recreate_sync(sami, lang, primary, time)
+
+ p = sami.new_tag("p")
+
+ p_style = ""
+ for attr, value in self._recreate_style(caption.style).items():
+ p_style += f"{attr}:{value};"
+
+ if caption.layout_info and caption.layout_info.alignment:
+ h = caption.layout_info.alignment.horizontal
+ if h:
+ css_align = HORIZONTAL_ALIGNMENT_MAP.get(h)
+ if css_align:
+ p_style += f"text-align:{css_align};"
+
+ if p_style:
+ p["p_style"] = p_style
+
+ p["class"] = self._recreate_p_lang(caption, lang, captions)
+ p.string = self._recreate_text(caption.nodes)
+
+ sync.append(p)
+
+ return sami
+
+ def _recreate_sync(self, sami, lang, primary, time):
+ """Find or create a tag, preferring the language class."""
+ try:
+ if "lang" in captions.get_style(caption.style["class"]):
+ return caption.style["class"]
+ except KeyError:
+ pass
+ return lang
+
+ def _recreate_stylesheet(self, caption_set):
+ """Generate the CSS stylesheet block placed inside
(break), // (style),
+ and (class/inline-style).
+ """
+ if isinstance(tag, NavigableString):
+ tag_text = str(tag)
+ if tag_text and tag_text[0] in "\n\r":
+ tag_text = tag_text.lstrip()
+ tag_text = tag_text.rstrip("\n\r")
+ if not tag_text:
+ return
+ self.line.append(CaptionNode.create_text(tag_text, inherit_from))
+ elif tag.name == "br":
+ self.line.append(CaptionNode.create_break(inherit_from))
+ elif tag.name == "i" or tag.name == "b" or tag.name == "u":
+ style_name = _TAG_TO_STYLE.get(tag.name)
+ self.line.append(CaptionNode.create_style(True, {style_name: True}))
+ for a in tag.contents:
+ self._translate_tag(a, inherit_from)
+ self.line.append(CaptionNode.create_style(False, {style_name: True}))
+ elif tag.name == "span":
+ self._translate_span(tag, inherit_from)
+ else:
+ for a in tag.contents:
+ self._translate_tag(a, inherit_from)
+
+ def _translate_span(self, tag, inherit_from=None):
+ """Convert a tag into style-start/end nodes with layout.
+
+ If the span has styling attributes, wraps children between style
+ nodes. Otherwise processes children without wrapping.
+ """
+ args = self._translate_attrs(tag)
+ if args:
+ layout_info = self._build_layout(args, inherit_from)
+ node = CaptionNode.create_style(True, args, layout_info)
+ self.line.append(node)
+ for a in tag.contents:
+ self._translate_tag(a, layout_info)
+ node = CaptionNode.create_style(False, args, layout_info)
+ self.line.append(node)
+ else:
+ for a in tag.contents:
+ self._translate_tag(a, inherit_from)
+
+ def _translate_attrs(self, tag):
+ """Extract class, id, and inline style from a tag into a style dict.
+
+ :rtype: dict
+ """
+ attrs = {}
+ css_attrs = tag.attrs
+
+ if "class" in css_attrs:
+ attrs["class"] = css_attrs["class"][0].lower()
+ if "id" in css_attrs:
+ attrs["class"] = css_attrs["id"].lower()
+ if "style" in css_attrs:
+ styles = css_attrs["style"].split(";")
+ attrs.update(self._translate_style(attrs, styles))
+
+ return attrs
+
+ def _translate_style(self, attrs, styles):
+ """Parse inline CSS declarations into the style dict.
+
+ text-align is captured separately as a Layout alignment; other
+ properties are delegated to _translate_css_property.
+
+ :rtype: dict
+ """
+ for style in styles:
+ style = style.split(":")
+ if len(style) == 2:
+ css_property, value = style
+ else:
+ continue
+ if css_property == "text-align":
+ self._save_first_alignment(value.strip())
+ else:
+ self._translate_css_property(attrs, css_property, value)
+
+ return attrs
+
+ def _translate_parsed_style(self, styles):
+ """Normalize a stylesheet rule dict by mapping CSS names to internal keys.
+
+ Mutates the dict in-place (e.g. font-style:italic → italics:True).
+
+ :rtype: dict
+ """
+ attrs = styles
+ for css_property in list(styles.keys()):
+ value = styles[css_property]
+ self._translate_css_property(attrs, css_property, value)
+
+ return attrs
+
+ @staticmethod
+ def _translate_css_property(attrs, css_property, value):
+ """Map a single CSS property to pycaption's internal style key.
+
+ Recognized properties: font-family, font-size, font-style (italic),
+ text-decoration (underline), font-weight (bold), lang, color.
+ """
+ if css_property == "font-family":
+ attrs["font-family"] = value.strip()
+ elif css_property == "font-size":
+ attrs["font-size"] = value.strip()
+ elif css_property == "font-style" and value.strip() == "italic":
+ attrs["italics"] = True
+ elif css_property == "text-decoration" and value.strip() == "underline":
+ attrs["underline"] = True
+ elif css_property == "font-weight" and value.strip() == "bold":
+ attrs["bold"] = True
+ elif css_property == "lang":
+ attrs["lang"] = value.strip()
+ elif css_property == "color":
+ attrs["color"] = value.strip()
+
+ def _save_first_alignment(self, align):
+ """Capture the first text-align value encountered in a caption.
+
+ Only the first alignment wins — subsequent spans with different
+ alignments are ignored, since SAMI normalizes to one alignment
+ per caption.
+ """
+ if not self.first_alignment:
+ self.first_alignment = Alignment.from_horizontal_and_vertical_align(
+ text_align=align
+ )
diff --git a/pycaption/sami/writer.py b/pycaption/sami/writer.py
new file mode 100644
index 00000000..46f32486
--- /dev/null
+++ b/pycaption/sami/writer.py
@@ -0,0 +1,267 @@
+from copy import deepcopy
+from xml.sax.saxutils import escape
+
+from bs4 import BeautifulSoup
+
+from ..base import BaseWriter, CaptionNode
+from .constants import HORIZONTAL_ALIGNMENT_MAP, SAMI_BASE_MARKUP
+
+_NON_CSS_KEYS = frozenset(
+ {
+ "classes",
+ "class",
+ "ruby",
+ "ruby_text",
+ "timestamp",
+ "name",
+ "sami_type",
+ "lang",
+ "webvtt_positioning",
+ "writing_direction",
+ }
+)
+
+
+class SAMIWriter(BaseWriter):
+ """Writes a CaptionSet to SAMI markup."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.open_span = False
+ self.last_time = None
+
+ def write(self, caption_set):
+ """Serialize a CaptionSet into a SAMI document string."""
+ caption_set = deepcopy(caption_set)
+ sami = BeautifulSoup(SAMI_BASE_MARKUP, "lxml-xml")
+
+ caption_set.layout_info = self._relativize_and_fit_to_screen(
+ caption_set.layout_info
+ )
+
+ primary = None
+
+ for lang in caption_set.get_languages():
+ self.last_time = None
+ if primary is None:
+ primary = lang
+
+ caption_set.set_layout_info(
+ lang,
+ self._relativize_and_fit_to_screen(caption_set.get_layout_info(lang)),
+ )
+
+ for caption in caption_set.get_captions(lang):
+ caption.layout_info = self._relativize_and_fit_to_screen(
+ caption.layout_info
+ )
+ for node in caption.nodes:
+ node.layout_info = self._relativize_and_fit_to_screen(
+ node.layout_info
+ )
+ sami = self._recreate_p_tag(caption, sami, lang, primary, caption_set)
+
+ stylesheet = self._recreate_stylesheet(caption_set)
+ sami.find("style").append(stylesheet)
+
+ a = sami.prettify(formatter=None).split("\n")
+ caption_content = "\n".join(a[1:])
+ return caption_content
+
+ def _recreate_p_tag(self, caption, sami, lang, primary, captions):
+ """Build a