Files

764 lines
24 KiB
Python

"""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 '
'<text> or <tspan>',
)
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 '
'<svg>, <g>, <text>, or <tspan>',
)
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 <tspan>; place it on the '
'containing <text> 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', '<malformed>', 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'
]