"""Closed project grammar for SVG text presentation properties. The project accepts a deliberately small SVG text surface that maps deterministically to editable DrawingML. This module is shared by the quality checker and the converter so unsupported values cannot be silently normalized by one route and rejected by the other. """ from __future__ import annotations import math import re from dataclasses import dataclass from xml.etree import ElementTree as ET from .utils import font_px_to_hpt, parse_svg_length _SVG_TEXT_PROPERTIES = frozenset({ 'font-weight', 'font-style', 'text-anchor', 'letter-spacing', 'text-decoration', }) _TEXT_DECLARATION_PROPERTIES = _SVG_TEXT_PROPERTIES | { 'font-family', 'font-size', } _TEXT_INHERITANCE_TARGETS = frozenset({'svg', 'g', 'text', 'tspan'}) _UNSUPPORTED_TEXT_PROPERTIES = frozenset({ 'alignment-baseline', 'baseline-shift', 'direction', 'dominant-baseline', 'font-kerning', 'font-feature-settings', 'font-size-adjust', 'font-stretch', 'font-synthesis', 'font-variant', 'font-variation-settings', 'font', 'hyphens', 'kerning', 'line-height', 'overflow-wrap', 'text-align', 'text-align-last', 'text-indent', 'text-rendering', 'text-shadow', 'text-transform', 'unicode-bidi', 'vertical-align', 'white-space', 'word-spacing', 'word-break', 'writing-mode', }) _TEXT_DIRECT_ATTRIBUTES = frozenset({ 'fill', 'fill-opacity', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'id', 'letter-spacing', 'opacity', 'stroke', 'stroke-opacity', 'stroke-width', 'style', 'text-anchor', 'text-decoration', 'transform', 'x', 'xml:space', 'y', }) _TSPAN_DIRECT_ATTRIBUTES = frozenset({ 'dx', 'dy', 'fill', 'fill-opacity', 'font-family', 'font-size', 'font-style', 'font-weight', 'id', 'letter-spacing', 'opacity', 'stroke', 'stroke-opacity', 'stroke-width', 'style', 'text-decoration', 'x', 'xml:space', 'y', }) _TEXT_INLINE_PROPERTIES = frozenset({ 'fill', 'fill-opacity', 'font-family', 'font-size', 'font-style', 'font-weight', 'letter-spacing', 'opacity', 'shape-rendering', 'stroke', 'stroke-opacity', 'stroke-width', 'text-anchor', 'text-decoration', }) _TSPAN_INLINE_PROPERTIES = _TEXT_INLINE_PROPERTIES - {'text-anchor'} _CANONICAL_DECIMAL_RE = re.compile(r'^-?(?:\d+(?:\.\d+)?|\.\d+)$') _COMPATIBLE_LETTER_SPACING_RE = re.compile( r'(-?(?:\d+(?:\.\d+)?|\.\d+))(px|pt|em)', re.IGNORECASE, ) _XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace' _XML_SPACE_ATTRIBUTE = f'{{{_XML_NAMESPACE}}}space' _PROJECT_XML_SPACE_VALUES = frozenset({'default', 'preserve'}) _DRAWINGML_TEXT_SPACING_MIN = -400_000 _DRAWINGML_TEXT_SPACING_MAX = 400_000 @dataclass(frozen=True) class ParsedTextProperty: """One validated text-property value and its canonical representation.""" value: object canonical: str compatible: bool = False @dataclass(frozen=True) class TextPropertyDiagnostic: """Stable checker/converter diagnostic for one text declaration.""" severity: str label: str source: str name: str raw: str message: str canonical: str | None = None def _local_name(value: object) -> str: text = str(value) return text.rsplit('}', 1)[-1] if '}' in text else text def _element_label(elem: ET.Element) -> str: tag = _local_name(elem.tag) elem_id = elem.get('id') return f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>' def _attribute_name(raw_name: str) -> str: if raw_name.startswith(f'{{{_XML_NAMESPACE}}}'): return f'xml:{raw_name.rsplit("}", 1)[-1]}' return _local_name(raw_name) def resolve_project_xml_space( elem: ET.Element, inherited: str = 'default', ) -> str: """Resolve the exact project ``xml:space`` value for one text element.""" if inherited not in _PROJECT_XML_SPACE_VALUES: raise ValueError(f'invalid inherited xml:space value {inherited!r}') raw = elem.get(_XML_SPACE_ATTRIBUTE) if raw is None: raw = elem.get('xml:space') if raw is None: return inherited if raw not in _PROJECT_XML_SPACE_VALUES: raise ValueError("xml:space must be exactly 'default' or 'preserve'") return raw def normalize_project_text_segments( segments: list[tuple[str, str]], ) -> list[tuple[int, str]]: """Normalize text whitespace while retaining the source segment owner. Each input tuple is ``(effective_xml_space, raw_text)``. The returned tuples are ``(input_index, normalized_text)`` so callers can retain run formatting. Project whitespace follows rendered SVG behavior: tabs and line endings become ordinary spaces; ``default`` runs collapse across element boundaries and lose only overall leading/trailing spaces; ``preserve`` runs retain every resulting ordinary space. Unicode spacing characters such as NBSP are text, not XML whitespace, and remain intact. """ output: list[tuple[int, str]] = [] pending_default_space: int | None = None def append(index: int, text: str) -> None: if not text: return if output and output[-1][0] == index: owner, existing = output[-1] output[-1] = (owner, existing + text) else: output.append((index, text)) def flush_pending() -> None: nonlocal pending_default_space if pending_default_space is not None and output: append(pending_default_space, ' ') pending_default_space = None for index, (xml_space, raw_text) in enumerate(segments): if xml_space not in _PROJECT_XML_SPACE_VALUES: raise ValueError( f'xml:space must be exactly default or preserve; got ' f'{xml_space!r}' ) text = re.sub(r'[\t\r\n]', ' ', raw_text) for char in text: if xml_space == 'default' and char == ' ': if pending_default_space is None: pending_default_space = index continue flush_pending() append(index, char) # A pending default-mode space is the overall trailing space and is # intentionally discarded. Preserved trailing spaces were emitted inline. return output def _is_unregistered_prefixed_text_property(name: str) -> bool: lowered = name.lower() return ( lowered.startswith(('font-', 'text-')) and lowered not in _TEXT_DECLARATION_PROPERTIES ) def _format_decimal(value: float) -> str: if abs(value) < 1e-15: return '0' text = f'{value:.15f}'.rstrip('0').rstrip('.') return '0' if text in {'', '-0'} else text def parse_project_font_weight(raw: str) -> ParsedTextProperty: """Parse the closed project font-weight grammar.""" if raw in {'normal', 'bold'}: return ParsedTextProperty(raw == 'bold', raw) if raw in {str(value) for value in range(100, 1000, 100)}: return ParsedTextProperty(int(raw) >= 600, raw) aliases = {'medium': '500', 'semibold': '600'} if raw in aliases: canonical = aliases[raw] return ParsedTextProperty(int(canonical) >= 600, canonical, True) raise ValueError( "expected 'normal', 'bold', or an integer weight from 100 through 900" ) def parse_project_font_style(raw: str) -> ParsedTextProperty: """Parse the closed project font-style grammar.""" if raw not in {'normal', 'italic'}: raise ValueError("expected 'normal' or 'italic'") return ParsedTextProperty(raw == 'italic', raw) def parse_project_text_anchor(raw: str) -> ParsedTextProperty: """Parse the closed project text-anchor grammar.""" if raw not in {'start', 'middle', 'end'}: raise ValueError("expected 'start', 'middle', or 'end'") return ParsedTextProperty(raw, raw) def parse_project_text_decoration(raw: str) -> ParsedTextProperty: """Parse text decoration without substring-based false positives.""" canonical = { 'none': 'none', 'underline': 'underline', 'line-through': 'line-through', 'underline line-through': 'underline line-through', } if raw in canonical: value = ( 'underline' in raw.split(), 'line-through' in raw.split(), ) return ParsedTextProperty(value, canonical[raw]) if raw == 'line-through underline': return ParsedTextProperty( (True, True), 'underline line-through', True, ) raise ValueError( "expected 'none', 'underline', 'line-through', or " "'underline line-through'" ) def parse_project_letter_spacing( raw: str, *, font_size: float = 16.0, scale_x: float = 1.0, ) -> ParsedTextProperty: """Parse project tracking into scaled SVG pixels and validate DML range.""" if _CANONICAL_DECIMAL_RE.fullmatch(raw): amount = float(raw) unit = '' compatible = False else: match = _COMPATIBLE_LETTER_SPACING_RE.fullmatch(raw) if match is None: raise ValueError( 'expected a finite ordinary decimal, optionally followed by ' 'the registered compatible unit px, pt, or em' ) amount = float(match.group(1)) unit = match.group(2).lower() compatible = True if not math.isfinite(amount): raise ValueError('must be finite') if not math.isfinite(font_size) or font_size <= 0: raise ValueError('requires a finite positive effective font size') if not math.isfinite(scale_x) or scale_x <= 0: raise ValueError('requires a finite positive horizontal scale') if unit == 'em': value_px = amount * font_size elif unit == 'pt': value_px = amount * 4.0 / 3.0 * scale_x else: value_px = amount * scale_x spacing = round(value_px * 75) if not _DRAWINGML_TEXT_SPACING_MIN <= spacing <= _DRAWINGML_TEXT_SPACING_MAX: raise ValueError( 'converts outside the DrawingML character-spacing range ' f'{_DRAWINGML_TEXT_SPACING_MIN}..{_DRAWINGML_TEXT_SPACING_MAX}' ) return ParsedTextProperty( value_px, _format_decimal(value_px), compatible, ) def drawingml_letter_spacing(value_px: float) -> int: """Return validated DrawingML ``a:rPr@spc`` hundredths-of-a-point.""" if not math.isfinite(value_px): raise ValueError('letter-spacing must be finite') spacing = round(value_px * 75) if not _DRAWINGML_TEXT_SPACING_MIN <= spacing <= _DRAWINGML_TEXT_SPACING_MAX: raise ValueError( 'letter-spacing converts outside the DrawingML range ' f'{_DRAWINGML_TEXT_SPACING_MIN}..{_DRAWINGML_TEXT_SPACING_MAX}' ) return spacing def parse_project_text_property( name: str, raw: str, *, font_size: float = 16.0, ) -> ParsedTextProperty: """Parse one declaration from the shared text-property value contract.""" parsers = { 'font-weight': parse_project_font_weight, 'font-style': parse_project_font_style, 'text-anchor': parse_project_text_anchor, 'letter-spacing': parse_project_letter_spacing, 'text-decoration': parse_project_text_decoration, } parser = parsers.get(name) if parser is None: raise ValueError(f'unsupported project text property {name!r}') if name == 'letter-spacing': return parser(raw, font_size=font_size) return parser(raw) def _iter_style_declarations( elem: ET.Element, ) -> tuple[list[tuple[str, str]], list[str]]: declarations: list[tuple[str, str]] = [] malformed: list[str] = [] for raw_fragment in (elem.get('style') or '').split(';'): fragment = raw_fragment.strip() if not fragment: continue if ':' not in fragment: malformed.append(fragment) continue raw_name, raw_value = fragment.split(':', 1) name = raw_name.strip().lower() value = raw_value.strip() if not name or not value: malformed.append(fragment) continue declarations.append((name, value)) return declarations, malformed def _resolve_font_sizes( root: ET.Element, ) -> tuple[dict[int, float], list[TextPropertyDiagnostic]]: """Resolve inherited font sizes and retain declaration-level failures.""" resolved: dict[int, float] = {} diagnostics: list[TextPropertyDiagnostic] = [] def parse_declared_size( elem: ET.Element, raw: str, source: str, parent_size: float, root_size: float, ) -> float | None: label = _element_label(elem) relative_base = ( root_size if raw.strip().lower().endswith('rem') else parent_size ) try: value = parse_svg_length( raw, parent_size, font_size=relative_base, ) font_px_to_hpt(value) except ValueError as exc: diagnostics.append(TextPropertyDiagnostic( 'error', label, source, 'font-size', raw, f'{label} {source} font-size={raw!r}: {exc}', )) return None return value def walk( elem: ET.Element, parent_size: float, root_size: float, ) -> None: declarations, _ = _iter_style_declarations(elem) style_sizes = [ raw for name, raw in declarations if name == 'font-size' ] direct_raw = elem.get('font-size') direct_size = ( parse_declared_size( elem, direct_raw, 'attribute', parent_size, root_size, ) if direct_raw is not None else None ) parsed_style_sizes = [ parse_declared_size( elem, raw, 'inline style', parent_size, root_size, ) for raw in style_sizes ] effective_size = parent_size if style_sizes: last_style_size = parsed_style_sizes[-1] effective_size = ( last_style_size if last_style_size is not None else parent_size ) elif direct_raw is not None: effective_size = direct_size if direct_size is not None else parent_size resolved[id(elem)] = effective_size child_root_size = effective_size if elem is root else root_size for child in elem: walk(child, effective_size, child_root_size) walk(root, 16.0, 16.0) return resolved, diagnostics def resolve_project_font_sizes(root: ET.Element) -> dict[int, float]: """Return effective SVG font sizes or reject an invalid declaration.""" resolved, diagnostics = _resolve_font_sizes(root) if diagnostics: raise ValueError('; '.join(item.message for item in diagnostics[:8])) return resolved def resolve_project_letter_spacings( root: ET.Element, font_sizes: dict[int, float] | None = None, ) -> dict[int, float]: """Resolve tracking at its declaration site before it is inherited.""" effective_font_sizes = font_sizes or resolve_project_font_sizes(root) resolved: dict[int, float] = {} def walk(elem: ET.Element, parent_spacing: float) -> None: declarations, _ = _iter_style_declarations(elem) style = dict(declarations) direct_raw = elem.get('letter-spacing') style_raw = style.get('letter-spacing') effective_spacing = parent_spacing if style_raw is not None: effective_spacing = float(parse_project_letter_spacing( style_raw, font_size=effective_font_sizes[id(elem)], ).value) elif direct_raw is not None: effective_spacing = float(parse_project_letter_spacing( direct_raw, font_size=effective_font_sizes[id(elem)], ).value) resolved[id(elem)] = effective_spacing for child in elem: walk(child, effective_spacing) walk(root, 0.0) return resolved def materialize_project_text_metrics(root: ET.Element) -> int: """Lower relative text metrics before positional tspan restructuring.""" font_sizes = resolve_project_font_sizes(root) letter_spacings = resolve_project_letter_spacings(root, font_sizes) materialized = 0 for elem in root.iter(): canonical_font_size = _format_decimal(font_sizes[id(elem)]) canonical_letter_spacing = _format_decimal(letter_spacings[id(elem)]) if elem.get('font-size') is not None: elem.set('font-size', canonical_font_size) materialized += 1 if elem.get('letter-spacing') is not None: elem.set('letter-spacing', canonical_letter_spacing) materialized += 1 style = elem.get('style') if not style: continue retained: list[str] = [] changed = False for raw_fragment in style.split(';'): fragment = raw_fragment.strip() if not fragment: continue if ':' not in fragment: retained.append(fragment) continue raw_name, _ = fragment.split(':', 1) name = raw_name.strip().lower() if name == 'font-size': retained.append(f'font-size:{canonical_font_size}') changed = True materialized += 1 elif name == 'letter-spacing': retained.append( f'letter-spacing:{canonical_letter_spacing}' ) changed = True materialized += 1 else: retained.append(fragment) if changed: elem.set('style', '; '.join(retained)) return materialized def _diagnose_text_declaration( elem: ET.Element, *, tag: str, source: str, name: str, raw: str, font_size: float, ) -> tuple[bool, TextPropertyDiagnostic | None]: """Return whether a declaration belongs to the text contract and its issue.""" label = _element_label(elem) if name == 'xml:space': if source != 'attribute' or tag not in {'text', 'tspan'}: return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} can use xml:space only as a direct attribute on ' ' or ', ) if raw not in _PROJECT_XML_SPACE_VALUES: return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} attribute xml:space={raw!r}: expected exactly ' "'default' or 'preserve'", ) return True, None if _is_unregistered_prefixed_text_property(name): return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} uses unregistered inherited text property {name!r}; ' 'native PPTX export would ignore it', ) if ( name in _TEXT_DECLARATION_PROPERTIES and tag not in _TEXT_INHERITANCE_TARGETS ): return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} cannot carry text property {name!r}; place it on ' ', , , or ', ) if name in _UNSUPPORTED_TEXT_PROPERTIES: return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} uses unsupported text property {name!r}; ' 'it has no registered DrawingML mapping', ) if name not in _SVG_TEXT_PROPERTIES: return name in _TEXT_DECLARATION_PROPERTIES, None if tag == 'tspan' and name == 'text-anchor': return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} cannot use text-anchor on ; place it on the ' 'containing or an ancestor group', ) try: parsed = parse_project_text_property( name, raw, font_size=font_size, ) except ValueError as exc: return True, TextPropertyDiagnostic( 'error', label, source, name, raw, f'{label} {source} {name}={raw!r}: {exc}', ) if parsed.compatible: return True, TextPropertyDiagnostic( 'warning', label, source, name, raw, f'{label} {source} {name}={raw!r} is compatible; ' f'prefer {name}={parsed.canonical!r}', parsed.canonical, ) return True, None def project_text_property_diagnostics( root: ET.Element, ) -> list[TextPropertyDiagnostic]: """Validate the closed text attribute/value surface for one SVG tree.""" font_sizes, diagnostics = _resolve_font_sizes(root) for elem in root.iter(): tag = _local_name(elem.tag) label = _element_label(elem) direct_allowlist = { 'text': _TEXT_DIRECT_ATTRIBUTES, 'tspan': _TSPAN_DIRECT_ATTRIBUTES, }.get(tag) inline_allowlist = { 'text': _TEXT_INLINE_PROPERTIES, 'tspan': _TSPAN_INLINE_PROPERTIES, }.get(tag) for raw_name, raw in elem.attrib.items(): name = _attribute_name(raw_name) if name == 'style' or name.startswith('data-'): continue handled, diagnostic = _diagnose_text_declaration( elem, tag=tag, source='attribute', name=name, raw=raw, font_size=font_sizes[id(elem)], ) if diagnostic is not None: diagnostics.append(diagnostic) elif ( not handled and direct_allowlist is not None and name not in direct_allowlist ): diagnostics.append(TextPropertyDiagnostic( 'error', label, 'attribute', name, raw, f'{label} uses unsupported text attribute {name!r}; ' 'native PPTX export would ignore it', )) declarations, malformed = _iter_style_declarations(elem) for fragment in malformed: property_hint = fragment.split(None, 1)[0].lower() if ( inline_allowlist is not None or property_hint in _TEXT_DECLARATION_PROPERTIES or property_hint in _UNSUPPORTED_TEXT_PROPERTIES or _is_unregistered_prefixed_text_property(property_hint) ): diagnostics.append(TextPropertyDiagnostic( 'error', label, 'inline style', '', fragment, f'{label} has malformed inline style declaration {fragment!r}', )) for name, raw in declarations: handled, diagnostic = _diagnose_text_declaration( elem, tag=tag, source='inline style', name=name, raw=raw, font_size=font_sizes[id(elem)], ) if diagnostic is not None: diagnostics.append(diagnostic) elif ( not handled and inline_allowlist is not None and name not in inline_allowlist ): diagnostics.append(TextPropertyDiagnostic( 'error', label, 'inline style', name, raw, f'{label} uses unsupported inline text property {name!r}; ' 'native PPTX export would ignore it', )) return diagnostics def project_text_property_errors(root: ET.Element) -> list[str]: """Return blocking diagnostics for the converter preflight.""" return [ diagnostic.message for diagnostic in project_text_property_diagnostics(root) if diagnostic.severity == 'error' ]