764 lines
24 KiB
Python
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'
|
|
]
|