Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
"""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'
|
||||
]
|
||||
Reference in New Issue
Block a user