Source code for ssmd.ssml_parser

"""SSML to SSMD converter - reverse conversion."""

import re
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Any

from ssmd.formatter import format_ssmd
from ssmd.parser import parse_sentences
from ssmd.ssml_conversions import SSML_BREAK_STRENGTH_MAP
from ssmd.utils import (
    _PLACEHOLDER_MAP,
    escape_ssmd_syntax,
    format_ssmd_attr,
    unescape_ssmd_syntax,
)

if TYPE_CHECKING:
    from ssmd.capabilities import TTSCapabilities


[docs] class SSMLParser: """Convert SSML to SSMD markdown format. This class provides the reverse conversion from SSML XML to the more human-readable SSMD markdown syntax. Literal text in the SSML is placeholder-escaped before reparsing so characters that look like SSMD markup (``*``, ``[...]``, ``@``, ``...2s``) survive verbatim. Reverse conversion is best-effort: unknown vendor-specific tags are flattened to their children (their semantics are dropped), and inline annotations whose content itself contains ``[...]`` markup may be lossy. Prefer directive (``<div>``) blocks for nested content. Example: >>> parser = SSMLParser() >>> ssml = '<speak><emphasis>Hello</emphasis> world</speak>' >>> ssmd = parser.to_ssmd(ssml) >>> print(ssmd) '*Hello* world' """ # Standard locales that can be simplified (locale -> language code) STANDARD_LOCALES = { "en-US": "en", "en-GB": "en-GB", # Keep non-US English locales "de-DE": "de", "fr-FR": "fr", "es-ES": "es", "it-IT": "it", "pt-PT": "pt", "ru-RU": "ru", "zh-CN": "zh", "ja-JP": "ja", "ko-KR": "ko", }
[docs] def __init__(self, config: dict[str, Any] | None = None): """Initialize SSML parser. Args: config: Optional configuration dictionary """ self.config = config or {}
def _format_attr(self, key: str, value: str) -> str: return format_ssmd_attr(key, value) def _format_attrs(self, pairs: list[tuple[str, str]]) -> str: return " ".join(self._format_attr(key, value) for key, value in pairs) def _wrap_directive(self, content: str, attrs: str) -> str: content = content.strip() return f"<div {attrs}>{{DIRECTIVE_NEWLINE}}{content}{{DIRECTIVE_NEWLINE}}</div>" def _element_namespace(self, element: ET.Element) -> str | None: if element.tag.startswith("{"): return element.tag.split("}")[0][1:] return None def _local_tag(self, element: ET.Element) -> str: """Return an element's local tag name, ignoring any XML namespace.""" return element.tag.split("}")[-1] def _find_child(self, element: ET.Element, name: str) -> ET.Element | None: """Find the first child whose local tag matches ``name``. Resolves tags namespace-agnostically so vendor SSML that declares the SSML namespace (e.g. ``<ssml:desc>``) is still matched. """ for child in element: if self._local_tag(child) == name: return child return None def _escape_text(self, text: str) -> str: """Escape literal SSML text so it is not reparsed as SSMD markup. ``escape_ssmd_syntax`` protects complete SSMD patterns (emphasis pairs, annotations, marks, breaks, headings, directives). Isolated brackets are additionally placeholder-escaped so a literal ``]`` cannot terminate an enclosing annotation when this text becomes annotation content. Placeholders are restored by ``unescape_ssmd_syntax`` at the end of ``to_ssmd``. """ text = escape_ssmd_syntax(text) return ( text.replace("[", _PLACEHOLDER_MAP["["]).replace("]", _PLACEHOLDER_MAP["]"]) ) def _annotation(self, content: str, attrs: str) -> str: """Wrap ``content``/``attrs`` as an inline or block annotation. Inline form ``[content]{attrs}`` is used when the content is short, single-line, and free of ``[``/``]`` (literal or placeholder-escaped), because SSMD inline annotation content cannot represent a literal ``]`` or nested ``[...]`` markup. Block (multi-line/long) content uses the directive (``<div>``) form, whose content is not bracket-delimited. Short content that still contains a bracket degrades to the escaped text (the annotation semantics are dropped) so the bracket survives without corrupting surrounding SSMD. """ stripped = content.strip() is_block = "\n" in stripped or len(stripped) > 80 has_bracket = ( "[" in stripped or "]" in stripped or _PLACEHOLDER_MAP["["] in stripped or _PLACEHOLDER_MAP["]"] in stripped ) if is_block: return self._wrap_directive(content, attrs) if has_bracket: return content return f"[{content}]{{{attrs}}}"
[docs] def to_ssmd( self, ssml: str, *, capabilities: "TTSCapabilities | str | None" = None ) -> str: """Convert SSML to SSMD format. Args: ssml: SSML XML string capabilities: Optional TTS capabilities (preset name or object) Returns: SSMD markdown string with proper formatting (each sentence on new line) Example: >>> parser = SSMLParser() >>> parser.to_ssmd('<speak><emphasis>Hello</emphasis></speak>') '*Hello*' """ # Parse the input first; only wrap in <speak> if it is not already a # single root element. Parsing first (rather than a naive # startswith('<speak') check) handles XML declarations and processing # instructions that are already well-formed. try: root = ET.fromstring(ssml) except ET.ParseError: # Bare text and fragments without a root element are wrapped as a # convenience. try: root = ET.fromstring(f"<speak>{ssml}</speak>") except ET.ParseError as e: raise ValueError(f"Invalid SSML XML: {e}") from e # Process the root element result = self._process_element(root) # Clean up whitespace result = self._clean_whitespace(result) # Restore directive and sentence newlines (protected during whitespace cleaning) result = ( result.replace("{DIRECTIVE_NEWLINE}", "\n") .replace("{SENTENCE_NEWLINE}", "\n") .strip() ) # Parse into sentences and format with proper line breaks sentences = parse_sentences( result.strip(), capabilities=capabilities, strict_parse=capabilities is not None, ) formatted = format_ssmd(sentences) # Restore placeholder-escaped literal characters that were protected # above from being reparsed as SSMD markup. return unescape_ssmd_syntax(formatted)
def _process_element(self, element: ET.Element) -> str: """Process an XML element and its children recursively. Args: element: XML element to process Returns: SSMD formatted string """ tag = element.tag.split("}")[-1] # Remove namespace if present namespace = self._element_namespace(element) # Handle different SSML tags if tag == "speak": return self._process_children(element) elif tag == "p": content = self._process_children(element) # Paragraphs are separated by double newlines return f"{content}\n\n" elif tag == "s": # Sentences - preserve explicit line breaks return f"{self._process_children(element)}{{SENTENCE_NEWLINE}}" elif tag == "emphasis": return self._process_emphasis(element) elif tag == "break": return self._process_break(element) elif tag == "prosody": return self._process_prosody(element) elif tag == "lang": return self._process_language(element) elif tag == "voice": return self._process_voice(element) elif tag == "phoneme": return self._process_phoneme(element) elif tag == "sub": return self._process_substitution(element) elif tag == "say-as": return self._process_say_as(element) elif tag == "audio": return self._process_audio(element) elif tag == "mark": return self._process_mark(element) elif tag == "effect" and namespace == "https://amazon.com/ssml": return self._process_amazon_effect(element) else: # Unknown/vendor-specific tag: drop the tag's semantics and keep # only its children. This is intentional flattening (see above). return self._process_children(element) def _process_children(self, element: ET.Element) -> str: """Process all children of an element. Args: element: Parent element Returns: Combined SSMD string from all children """ result = [] # Add text before first child if element.text: result.append(self._escape_text(element.text)) # Process each child for child in element: result.append(self._process_element(child)) # Add text after child if child.tail: result.append(self._escape_text(child.tail)) result_text = "".join(result) return re.sub(r"\s+\n\n\s+", "\n\n", result_text) def _process_emphasis(self, element: ET.Element) -> str: """Convert <emphasis> to *text*, **text**, or _text_. Args: element: emphasis element Returns: SSMD emphasis syntax """ content = self._process_children(element) level = element.get("level", "moderate") if level in ("strong", "x-strong"): return f"**{content}**" elif level == "reduced": return f"_{content}_" elif level == "none": # Level "none" is rare - use explicit annotation return self._annotation(content, self._format_attr("emphasis", "none")) else: # moderate or default return f"*{content}*" def _process_break(self, element: ET.Element) -> str: """Convert <break> to ... notation. Args: element: break element Returns: SSMD break syntax with spaces """ time = element.get("time") strength = element.get("strength") if time: # Parse time value (e.g., "500ms", "2s") match = re.match(r"(\d+(?:\.\d+)?)(ms|s)", time) if match: # Breaks have spaces before and after per SSMD spec return f" ...{time} " # Fallback to 1s if time format is invalid return " ...1s " elif strength: marker = SSML_BREAK_STRENGTH_MAP.get(strength, "...s") return f" {marker} " # Default to sentence break return " ...s " def _process_prosody(self, element: ET.Element) -> str: """Convert <prosody> to directive or inline annotation. Args: element: prosody element Returns: SSMD prosody syntax """ content = self._process_children(element) volume = element.get("volume") rate = element.get("rate") pitch = element.get("pitch") # Filter out "medium" default values (ssml-maker adds these) if volume == "medium": volume = None if rate == "medium": rate = None if pitch == "medium": pitch = None if not any([volume, rate, pitch]): return content pairs: list[tuple[str, str]] = [] if volume: pairs.append(("volume", volume)) if rate: pairs.append(("rate", rate)) if pitch: pairs.append(("pitch", pitch)) if not pairs: return content attrs = self._format_attrs(pairs) return self._annotation(content, attrs) def _process_language(self, element: ET.Element) -> str: """Convert <lang> to directive or inline annotation. Args: element: lang element Returns: SSMD language syntax """ content = self._process_children(element) lang = element.get("{http://www.w3.org/XML/1998/namespace}lang") or element.get( "lang" ) if not lang: return content simplified = self.STANDARD_LOCALES.get(lang, lang) lang_attr = self._format_attr("lang", simplified) return self._annotation(content, lang_attr) def _process_voice(self, element: ET.Element) -> str: """Convert <voice> to directive or annotation syntax. Uses directive syntax (<div ...>) for multi-line content, and annotation syntax ([text]{voice="name"}) for single-line content. Args: element: voice element Returns: SSMD voice syntax """ content = self._process_children(element) # Get voice attributes name = element.get("name") language = element.get("language") gender = element.get("gender") variant = element.get("variant") # Build voice attributes. Directive form is selected automatically by # _annotation when the content is multi-line or contains brackets. parts = [] if name: parts.append(self._format_attr("voice", name)) if language: parts.append(self._format_attr("voice-lang", language)) if gender: parts.append(self._format_attr("gender", gender)) if variant: parts.append(self._format_attr("variant", variant)) if not parts: return content attrs = " ".join(parts) return self._annotation(content, attrs) def _process_phoneme(self, element: ET.Element) -> str: """Convert <phoneme> to [text]{ph="..." alphabet="..."}. Args: element: phoneme element Returns: SSMD phoneme syntax """ content = self._process_children(element) alphabet = element.get("alphabet", "ipa") ph = element.get("ph", "") # Use explicit format: [text]{ph="value" alphabet="type"} attrs = self._format_attrs([("ph", ph), ("alphabet", alphabet)]) return self._annotation(content, attrs) def _process_substitution(self, element: ET.Element) -> str: """Convert <sub> to [text]{sub="alias"}. Args: element: sub element Returns: SSMD substitution syntax """ content = self._process_children(element) alias = element.get("alias", "") if alias: return self._annotation(content, self._format_attr("sub", alias)) return content def _process_say_as(self, element: ET.Element) -> str: """Convert <say-as> to [text]{as="type"}. Args: element: say-as element Returns: SSMD say-as syntax """ content = self._process_children(element) interpret_as = element.get("interpret-as", "") format_attr = element.get("format") detail_attr = element.get("detail") # Build annotation string parts = [self._format_attr("as", interpret_as)] if format_attr: parts.append(self._format_attr("format", format_attr)) if detail_attr: parts.append(self._format_attr("detail", detail_attr)) annotation = " ".join(parts) if interpret_as: return self._annotation(content, annotation) return content def _process_audio(self, element: ET.Element) -> str: """Convert <audio> to [desc]{src="url" ...}. The ``<desc>`` child is resolved namespace-agnostically. Single-sided clips (``clipBegin`` only or ``clipEnd`` only) are preserved, and fallback content is processed recursively so nested markup survives. Args: element: audio element Returns: SSMD audio syntax with attributes """ src = element.get("src", "") # Get advanced attributes clip_begin = element.get("clipBegin") clip_end = element.get("clipEnd") speed = element.get("speed") repeat_count = element.get("repeatCount") repeat_dur = element.get("repeatDur") sound_level = element.get("soundLevel") # Description from <desc> (resolved namespace-agnostically). desc_elem = self._find_child(element, "desc") description = "" has_desc_tag = False if desc_elem is not None: has_desc_tag = True description = self._process_children(desc_elem).strip() # Fallback content (rendered if the audio source is unavailable). # Process child elements recursively so nested markup survives instead # of only concatenating raw tails. fallback_parts: list[str] = [] if element.text: fallback_parts.append(self._escape_text(element.text)) for child in element: if child is desc_elem: if child.tail: fallback_parts.append(self._escape_text(child.tail)) continue fallback_parts.append(self._process_element(child)) if child.tail: fallback_parts.append(self._escape_text(child.tail)) fallback = re.sub(r"\s+", " ", "".join(fallback_parts)).strip() # If there's no <desc> tag but there is fallback content, # treat the fallback as description if not has_desc_tag and fallback: description = fallback if not src: return description if description else fallback pairs = [("src", src)] if clip_begin or clip_end: pairs.append(("clip", f"{clip_begin or ''}-{clip_end or ''}")) if speed: pairs.append(("speed", speed)) if repeat_count: pairs.append(("repeat", repeat_count)) if repeat_dur: pairs.append(("repeatDur", repeat_dur)) if sound_level: pairs.append(("level", sound_level)) if has_desc_tag and fallback: pairs.append(("alt", fallback)) annotation = self._format_attrs([(key, str(value)) for key, value in pairs]) if description: return self._annotation(description, annotation) return f"[]{{{annotation}}}" def _process_mark(self, element: ET.Element) -> str: """Convert <mark> to @name. Args: element: mark element Returns: SSMD mark syntax with spaces """ name = element.get("name", "") if name: # Marks have space before and after return f" @{name} " return "" def _process_amazon_effect(self, element: ET.Element) -> str: """Convert Amazon effects to [text]{ext="name"}. Args: element: amazon:effect element Returns: SSMD extension syntax """ content = self._process_children(element) name = element.get("name", "") # Map Amazon effect names to SSMD extensions effect_map = { "whispered": "whisper", "drc": "drc", } ext_name = effect_map.get(name, name) if ext_name: return self._annotation(content, self._format_attr("ext", ext_name)) return content def _clean_whitespace(self, text: str) -> str: """Clean up excessive whitespace while preserving paragraph breaks. Args: text: Text to clean Returns: Cleaned text """ # Preserve paragraph breaks (double newlines). Normalization is # narrowed: runs of spaces/tabs are collapsed and whitespace around # newlines is trimmed, but single newlines are preserved so literal # line breaks and directive content are not flattened to one space. text = text.strip("\n") parts = re.split(r"\n\n+", text) cleaned_parts = [] for part in parts: part = re.sub(r"[ \t]+", " ", part) part = re.sub(r" *\n *", "\n", part) part = re.sub(r"\n{3,}", "\n\n", part) cleaned = part.strip() if cleaned: cleaned_parts.append(cleaned) # Join with double newlines for paragraphs return "\n\n".join(cleaned_parts)