| 1 | """Closed project grammar for SVG text presentation properties. |
| 2 | |
| 3 | The project accepts a deliberately small SVG text surface that maps |
| 4 | deterministically to editable DrawingML. This module is shared by the quality |
| 5 | checker and the converter so unsupported values cannot be silently normalized |
| 6 | by one route and rejected by the other. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import math |
| 12 | import re |
| 13 | from dataclasses import dataclass |
| 14 | from xml.etree import ElementTree as ET |
| 15 | |
| 16 | from .utils import font_px_to_hpt, parse_svg_length |
| 17 | |
| 18 | |
| 19 | _SVG_TEXT_PROPERTIES = frozenset({ |
| 20 | 'font-weight', |
| 21 | 'font-style', |
| 22 | 'text-anchor', |
| 23 | 'letter-spacing', |
| 24 | 'text-decoration', |
| 25 | }) |
| 26 | |
| 27 | _TEXT_DECLARATION_PROPERTIES = _SVG_TEXT_PROPERTIES | { |
| 28 | 'font-family', |
| 29 | 'font-size', |
| 30 | } |
| 31 | |
| 32 | _TEXT_INHERITANCE_TARGETS = frozenset({'svg', 'g', 'text', 'tspan'}) |
| 33 | |
| 34 | _UNSUPPORTED_TEXT_PROPERTIES = frozenset({ |
| 35 | 'alignment-baseline', |
| 36 | 'baseline-shift', |
| 37 | 'direction', |
| 38 | 'dominant-baseline', |
| 39 | 'font-kerning', |
| 40 | 'font-feature-settings', |
| 41 | 'font-size-adjust', |
| 42 | 'font-stretch', |
| 43 | 'font-synthesis', |
| 44 | 'font-variant', |
| 45 | 'font-variation-settings', |
| 46 | 'font', |
| 47 | 'hyphens', |
| 48 | 'kerning', |
| 49 | 'line-height', |
| 50 | 'overflow-wrap', |
| 51 | 'text-align', |
| 52 | 'text-align-last', |
| 53 | 'text-indent', |
| 54 | 'text-rendering', |
| 55 | 'text-shadow', |
| 56 | 'text-transform', |
| 57 | 'unicode-bidi', |
| 58 | 'vertical-align', |
| 59 | 'white-space', |
| 60 | 'word-spacing', |
| 61 | 'word-break', |
| 62 | 'writing-mode', |
| 63 | }) |
| 64 | |
| 65 | _TEXT_DIRECT_ATTRIBUTES = frozenset({ |
| 66 | 'fill', |
| 67 | 'fill-opacity', |
| 68 | 'filter', |
| 69 | 'font-family', |
| 70 | 'font-size', |
| 71 | 'font-style', |
| 72 | 'font-weight', |
| 73 | 'id', |
| 74 | 'letter-spacing', |
| 75 | 'opacity', |
| 76 | 'stroke', |
| 77 | 'stroke-opacity', |
| 78 | 'stroke-width', |
| 79 | 'style', |
| 80 | 'text-anchor', |
| 81 | 'text-decoration', |
| 82 | 'transform', |
| 83 | 'x', |
| 84 | 'xml:space', |
| 85 | 'y', |
| 86 | }) |
| 87 | |
| 88 | _TSPAN_DIRECT_ATTRIBUTES = frozenset({ |
| 89 | 'dx', |
| 90 | 'dy', |
| 91 | 'fill', |
| 92 | 'fill-opacity', |
| 93 | 'font-family', |
| 94 | 'font-size', |
| 95 | 'font-style', |
| 96 | 'font-weight', |
| 97 | 'id', |
| 98 | 'letter-spacing', |
| 99 | 'opacity', |
| 100 | 'stroke', |
| 101 | 'stroke-opacity', |
| 102 | 'stroke-width', |
| 103 | 'style', |
| 104 | 'text-decoration', |
| 105 | 'x', |
| 106 | 'xml:space', |
| 107 | 'y', |
| 108 | }) |
| 109 | |
| 110 | _TEXT_INLINE_PROPERTIES = frozenset({ |
| 111 | 'fill', |
| 112 | 'fill-opacity', |
| 113 | 'font-family', |
| 114 | 'font-size', |
| 115 | 'font-style', |
| 116 | 'font-weight', |
| 117 | 'letter-spacing', |
| 118 | 'opacity', |
| 119 | 'shape-rendering', |
| 120 | 'stroke', |
| 121 | 'stroke-opacity', |
| 122 | 'stroke-width', |
| 123 | 'text-anchor', |
| 124 | 'text-decoration', |
| 125 | }) |
| 126 | |
| 127 | _TSPAN_INLINE_PROPERTIES = _TEXT_INLINE_PROPERTIES - {'text-anchor'} |
| 128 | |
| 129 | _CANONICAL_DECIMAL_RE = re.compile(r'^-?(?:\d+(?:\.\d+)?|\.\d+)$') |
| 130 | _COMPATIBLE_LETTER_SPACING_RE = re.compile( |
| 131 | r'(-?(?:\d+(?:\.\d+)?|\.\d+))(px|pt|em)', |
| 132 | re.IGNORECASE, |
| 133 | ) |
| 134 | _XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace' |
| 135 | _XML_SPACE_ATTRIBUTE = f'{{{_XML_NAMESPACE}}}space' |
| 136 | _PROJECT_XML_SPACE_VALUES = frozenset({'default', 'preserve'}) |
| 137 | _DRAWINGML_TEXT_SPACING_MIN = -400_000 |
| 138 | _DRAWINGML_TEXT_SPACING_MAX = 400_000 |
| 139 | |
| 140 | |
| 141 | @dataclass(frozen=True) |
| 142 | class ParsedTextProperty: |
| 143 | """One validated text-property value and its canonical representation.""" |
| 144 | |
| 145 | value: object |
| 146 | canonical: str |
| 147 | compatible: bool = False |
| 148 | |
| 149 | |
| 150 | @dataclass(frozen=True) |
| 151 | class TextPropertyDiagnostic: |
| 152 | """Stable checker/converter diagnostic for one text declaration.""" |
| 153 | |
| 154 | severity: str |
| 155 | label: str |
| 156 | source: str |
| 157 | name: str |
| 158 | raw: str |
| 159 | message: str |
| 160 | canonical: str | None = None |
| 161 | |
| 162 | |
| 163 | def _local_name(value: object) -> str: |
| 164 | text = str(value) |
| 165 | return text.rsplit('}', 1)[-1] if '}' in text else text |
| 166 | |
| 167 | |
| 168 | def _element_label(elem: ET.Element) -> str: |
| 169 | tag = _local_name(elem.tag) |
| 170 | elem_id = elem.get('id') |
| 171 | return f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>' |
| 172 | |
| 173 | |
| 174 | def _attribute_name(raw_name: str) -> str: |
| 175 | if raw_name.startswith(f'{{{_XML_NAMESPACE}}}'): |
| 176 | return f'xml:{raw_name.rsplit("}", 1)[-1]}' |
| 177 | return _local_name(raw_name) |
| 178 | |
| 179 | |
| 180 | def resolve_project_xml_space( |
| 181 | elem: ET.Element, |
| 182 | inherited: str = 'default', |
| 183 | ) -> str: |
| 184 | """Resolve the exact project ``xml:space`` value for one text element.""" |
| 185 | if inherited not in _PROJECT_XML_SPACE_VALUES: |
| 186 | raise ValueError(f'invalid inherited xml:space value {inherited!r}') |
| 187 | raw = elem.get(_XML_SPACE_ATTRIBUTE) |
| 188 | if raw is None: |
| 189 | raw = elem.get('xml:space') |
| 190 | if raw is None: |
| 191 | return inherited |
| 192 | if raw not in _PROJECT_XML_SPACE_VALUES: |
| 193 | raise ValueError("xml:space must be exactly 'default' or 'preserve'") |
| 194 | return raw |
| 195 | |
| 196 | |
| 197 | def normalize_project_text_segments( |
| 198 | segments: list[tuple[str, str]], |
| 199 | ) -> list[tuple[int, str]]: |
| 200 | """Normalize text whitespace while retaining the source segment owner. |
| 201 | |
| 202 | Each input tuple is ``(effective_xml_space, raw_text)``. The returned |
| 203 | tuples are ``(input_index, normalized_text)`` so callers can retain run |
| 204 | formatting. Project whitespace follows rendered SVG behavior: tabs and |
| 205 | line endings become ordinary spaces; ``default`` runs collapse across |
| 206 | element boundaries and lose only overall leading/trailing spaces; |
| 207 | ``preserve`` runs retain every resulting ordinary space. Unicode spacing |
| 208 | characters such as NBSP are text, not XML whitespace, and remain intact. |
| 209 | """ |
| 210 | output: list[tuple[int, str]] = [] |
| 211 | pending_default_space: int | None = None |
| 212 | |
| 213 | def append(index: int, text: str) -> None: |
| 214 | if not text: |
| 215 | return |
| 216 | if output and output[-1][0] == index: |
| 217 | owner, existing = output[-1] |
| 218 | output[-1] = (owner, existing + text) |
| 219 | else: |
| 220 | output.append((index, text)) |
| 221 | |
| 222 | def flush_pending() -> None: |
| 223 | nonlocal pending_default_space |
| 224 | if pending_default_space is not None and output: |
| 225 | append(pending_default_space, ' ') |
| 226 | pending_default_space = None |
| 227 | |
| 228 | for index, (xml_space, raw_text) in enumerate(segments): |
| 229 | if xml_space not in _PROJECT_XML_SPACE_VALUES: |
| 230 | raise ValueError( |
| 231 | f'xml:space must be exactly default or preserve; got ' |
| 232 | f'{xml_space!r}' |
| 233 | ) |
| 234 | text = re.sub(r'[\t\r\n]', ' ', raw_text) |
| 235 | for char in text: |
| 236 | if xml_space == 'default' and char == ' ': |
| 237 | if pending_default_space is None: |
| 238 | pending_default_space = index |
| 239 | continue |
| 240 | flush_pending() |
| 241 | append(index, char) |
| 242 | |
| 243 | # A pending default-mode space is the overall trailing space and is |
| 244 | # intentionally discarded. Preserved trailing spaces were emitted inline. |
| 245 | return output |
| 246 | |
| 247 | |
| 248 | def _is_unregistered_prefixed_text_property(name: str) -> bool: |
| 249 | lowered = name.lower() |
| 250 | return ( |
| 251 | lowered.startswith(('font-', 'text-')) |
| 252 | and lowered not in _TEXT_DECLARATION_PROPERTIES |
| 253 | ) |
| 254 | |
| 255 | |
| 256 | def _format_decimal(value: float) -> str: |
| 257 | if abs(value) < 1e-15: |
| 258 | return '0' |
| 259 | text = f'{value:.15f}'.rstrip('0').rstrip('.') |
| 260 | return '0' if text in {'', '-0'} else text |
| 261 | |
| 262 | |
| 263 | def parse_project_font_weight(raw: str) -> ParsedTextProperty: |
| 264 | """Parse the closed project font-weight grammar.""" |
| 265 | if raw in {'normal', 'bold'}: |
| 266 | return ParsedTextProperty(raw == 'bold', raw) |
| 267 | if raw in {str(value) for value in range(100, 1000, 100)}: |
| 268 | return ParsedTextProperty(int(raw) >= 600, raw) |
| 269 | aliases = {'medium': '500', 'semibold': '600'} |
| 270 | if raw in aliases: |
| 271 | canonical = aliases[raw] |
| 272 | return ParsedTextProperty(int(canonical) >= 600, canonical, True) |
| 273 | raise ValueError( |
| 274 | "expected 'normal', 'bold', or an integer weight from 100 through 900" |
| 275 | ) |
| 276 | |
| 277 | |
| 278 | def parse_project_font_style(raw: str) -> ParsedTextProperty: |
| 279 | """Parse the closed project font-style grammar.""" |
| 280 | if raw not in {'normal', 'italic'}: |
| 281 | raise ValueError("expected 'normal' or 'italic'") |
| 282 | return ParsedTextProperty(raw == 'italic', raw) |
| 283 | |
| 284 | |
| 285 | def parse_project_text_anchor(raw: str) -> ParsedTextProperty: |
| 286 | """Parse the closed project text-anchor grammar.""" |
| 287 | if raw not in {'start', 'middle', 'end'}: |
| 288 | raise ValueError("expected 'start', 'middle', or 'end'") |
| 289 | return ParsedTextProperty(raw, raw) |
| 290 | |
| 291 | |
| 292 | def parse_project_text_decoration(raw: str) -> ParsedTextProperty: |
| 293 | """Parse text decoration without substring-based false positives.""" |
| 294 | canonical = { |
| 295 | 'none': 'none', |
| 296 | 'underline': 'underline', |
| 297 | 'line-through': 'line-through', |
| 298 | 'underline line-through': 'underline line-through', |
| 299 | } |
| 300 | if raw in canonical: |
| 301 | value = ( |
| 302 | 'underline' in raw.split(), |
| 303 | 'line-through' in raw.split(), |
| 304 | ) |
| 305 | return ParsedTextProperty(value, canonical[raw]) |
| 306 | if raw == 'line-through underline': |
| 307 | return ParsedTextProperty( |
| 308 | (True, True), |
| 309 | 'underline line-through', |
| 310 | True, |
| 311 | ) |
| 312 | raise ValueError( |
| 313 | "expected 'none', 'underline', 'line-through', or " |
| 314 | "'underline line-through'" |
| 315 | ) |
| 316 | |
| 317 | |
| 318 | def parse_project_letter_spacing( |
| 319 | raw: str, |
| 320 | *, |
| 321 | font_size: float = 16.0, |
| 322 | scale_x: float = 1.0, |
| 323 | ) -> ParsedTextProperty: |
| 324 | """Parse project tracking into scaled SVG pixels and validate DML range.""" |
| 325 | if _CANONICAL_DECIMAL_RE.fullmatch(raw): |
| 326 | amount = float(raw) |
| 327 | unit = '' |
| 328 | compatible = False |
| 329 | else: |
| 330 | match = _COMPATIBLE_LETTER_SPACING_RE.fullmatch(raw) |
| 331 | if match is None: |
| 332 | raise ValueError( |
| 333 | 'expected a finite ordinary decimal, optionally followed by ' |
| 334 | 'the registered compatible unit px, pt, or em' |
| 335 | ) |
| 336 | amount = float(match.group(1)) |
| 337 | unit = match.group(2).lower() |
| 338 | compatible = True |
| 339 | |
| 340 | if not math.isfinite(amount): |
| 341 | raise ValueError('must be finite') |
| 342 | if not math.isfinite(font_size) or font_size <= 0: |
| 343 | raise ValueError('requires a finite positive effective font size') |
| 344 | if not math.isfinite(scale_x) or scale_x <= 0: |
| 345 | raise ValueError('requires a finite positive horizontal scale') |
| 346 | |
| 347 | if unit == 'em': |
| 348 | value_px = amount * font_size |
| 349 | elif unit == 'pt': |
| 350 | value_px = amount * 4.0 / 3.0 * scale_x |
| 351 | else: |
| 352 | value_px = amount * scale_x |
| 353 | |
| 354 | spacing = round(value_px * 75) |
| 355 | if not _DRAWINGML_TEXT_SPACING_MIN <= spacing <= _DRAWINGML_TEXT_SPACING_MAX: |
| 356 | raise ValueError( |
| 357 | 'converts outside the DrawingML character-spacing range ' |
| 358 | f'{_DRAWINGML_TEXT_SPACING_MIN}..{_DRAWINGML_TEXT_SPACING_MAX}' |
| 359 | ) |
| 360 | return ParsedTextProperty( |
| 361 | value_px, |
| 362 | _format_decimal(value_px), |
| 363 | compatible, |
| 364 | ) |
| 365 | |
| 366 | |
| 367 | def drawingml_letter_spacing(value_px: float) -> int: |
| 368 | """Return validated DrawingML ``a:rPr@spc`` hundredths-of-a-point.""" |
| 369 | if not math.isfinite(value_px): |
| 370 | raise ValueError('letter-spacing must be finite') |
| 371 | spacing = round(value_px * 75) |
| 372 | if not _DRAWINGML_TEXT_SPACING_MIN <= spacing <= _DRAWINGML_TEXT_SPACING_MAX: |
| 373 | raise ValueError( |
| 374 | 'letter-spacing converts outside the DrawingML range ' |
| 375 | f'{_DRAWINGML_TEXT_SPACING_MIN}..{_DRAWINGML_TEXT_SPACING_MAX}' |
| 376 | ) |
| 377 | return spacing |
| 378 | |
| 379 | |
| 380 | def parse_project_text_property( |
| 381 | name: str, |
| 382 | raw: str, |
| 383 | *, |
| 384 | font_size: float = 16.0, |
| 385 | ) -> ParsedTextProperty: |
| 386 | """Parse one declaration from the shared text-property value contract.""" |
| 387 | parsers = { |
| 388 | 'font-weight': parse_project_font_weight, |
| 389 | 'font-style': parse_project_font_style, |
| 390 | 'text-anchor': parse_project_text_anchor, |
| 391 | 'letter-spacing': parse_project_letter_spacing, |
| 392 | 'text-decoration': parse_project_text_decoration, |
| 393 | } |
| 394 | parser = parsers.get(name) |
| 395 | if parser is None: |
| 396 | raise ValueError(f'unsupported project text property {name!r}') |
| 397 | if name == 'letter-spacing': |
| 398 | return parser(raw, font_size=font_size) |
| 399 | return parser(raw) |
| 400 | |
| 401 | |
| 402 | def _iter_style_declarations( |
| 403 | elem: ET.Element, |
| 404 | ) -> tuple[list[tuple[str, str]], list[str]]: |
| 405 | declarations: list[tuple[str, str]] = [] |
| 406 | malformed: list[str] = [] |
| 407 | for raw_fragment in (elem.get('style') or '').split(';'): |
| 408 | fragment = raw_fragment.strip() |
| 409 | if not fragment: |
| 410 | continue |
| 411 | if ':' not in fragment: |
| 412 | malformed.append(fragment) |
| 413 | continue |
| 414 | raw_name, raw_value = fragment.split(':', 1) |
| 415 | name = raw_name.strip().lower() |
| 416 | value = raw_value.strip() |
| 417 | if not name or not value: |
| 418 | malformed.append(fragment) |
| 419 | continue |
| 420 | declarations.append((name, value)) |
| 421 | return declarations, malformed |
| 422 | |
| 423 | |
| 424 | def _resolve_font_sizes( |
| 425 | root: ET.Element, |
| 426 | ) -> tuple[dict[int, float], list[TextPropertyDiagnostic]]: |
| 427 | """Resolve inherited font sizes and retain declaration-level failures.""" |
| 428 | resolved: dict[int, float] = {} |
| 429 | diagnostics: list[TextPropertyDiagnostic] = [] |
| 430 | |
| 431 | def parse_declared_size( |
| 432 | elem: ET.Element, |
| 433 | raw: str, |
| 434 | source: str, |
| 435 | parent_size: float, |
| 436 | root_size: float, |
| 437 | ) -> float | None: |
| 438 | label = _element_label(elem) |
| 439 | relative_base = ( |
| 440 | root_size |
| 441 | if raw.strip().lower().endswith('rem') |
| 442 | else parent_size |
| 443 | ) |
| 444 | try: |
| 445 | value = parse_svg_length( |
| 446 | raw, |
| 447 | parent_size, |
| 448 | font_size=relative_base, |
| 449 | ) |
| 450 | font_px_to_hpt(value) |
| 451 | except ValueError as exc: |
| 452 | diagnostics.append(TextPropertyDiagnostic( |
| 453 | 'error', |
| 454 | label, |
| 455 | source, |
| 456 | 'font-size', |
| 457 | raw, |
| 458 | f'{label} {source} font-size={raw!r}: {exc}', |
| 459 | )) |
| 460 | return None |
| 461 | return value |
| 462 | |
| 463 | def walk( |
| 464 | elem: ET.Element, |
| 465 | parent_size: float, |
| 466 | root_size: float, |
| 467 | ) -> None: |
| 468 | declarations, _ = _iter_style_declarations(elem) |
| 469 | style_sizes = [ |
| 470 | raw |
| 471 | for name, raw in declarations |
| 472 | if name == 'font-size' |
| 473 | ] |
| 474 | direct_raw = elem.get('font-size') |
| 475 | direct_size = ( |
| 476 | parse_declared_size( |
| 477 | elem, |
| 478 | direct_raw, |
| 479 | 'attribute', |
| 480 | parent_size, |
| 481 | root_size, |
| 482 | ) |
| 483 | if direct_raw is not None |
| 484 | else None |
| 485 | ) |
| 486 | parsed_style_sizes = [ |
| 487 | parse_declared_size( |
| 488 | elem, |
| 489 | raw, |
| 490 | 'inline style', |
| 491 | parent_size, |
| 492 | root_size, |
| 493 | ) |
| 494 | for raw in style_sizes |
| 495 | ] |
| 496 | effective_size = parent_size |
| 497 | if style_sizes: |
| 498 | last_style_size = parsed_style_sizes[-1] |
| 499 | effective_size = ( |
| 500 | last_style_size |
| 501 | if last_style_size is not None |
| 502 | else parent_size |
| 503 | ) |
| 504 | elif direct_raw is not None: |
| 505 | effective_size = direct_size if direct_size is not None else parent_size |
| 506 | resolved[id(elem)] = effective_size |
| 507 | child_root_size = effective_size if elem is root else root_size |
| 508 | for child in elem: |
| 509 | walk(child, effective_size, child_root_size) |
| 510 | |
| 511 | walk(root, 16.0, 16.0) |
| 512 | return resolved, diagnostics |
| 513 | |
| 514 | |
| 515 | def resolve_project_font_sizes(root: ET.Element) -> dict[int, float]: |
| 516 | """Return effective SVG font sizes or reject an invalid declaration.""" |
| 517 | resolved, diagnostics = _resolve_font_sizes(root) |
| 518 | if diagnostics: |
| 519 | raise ValueError('; '.join(item.message for item in diagnostics[:8])) |
| 520 | return resolved |
| 521 | |
| 522 | |
| 523 | def resolve_project_letter_spacings( |
| 524 | root: ET.Element, |
| 525 | font_sizes: dict[int, float] | None = None, |
| 526 | ) -> dict[int, float]: |
| 527 | """Resolve tracking at its declaration site before it is inherited.""" |
| 528 | effective_font_sizes = font_sizes or resolve_project_font_sizes(root) |
| 529 | resolved: dict[int, float] = {} |
| 530 | |
| 531 | def walk(elem: ET.Element, parent_spacing: float) -> None: |
| 532 | declarations, _ = _iter_style_declarations(elem) |
| 533 | style = dict(declarations) |
| 534 | direct_raw = elem.get('letter-spacing') |
| 535 | style_raw = style.get('letter-spacing') |
| 536 | effective_spacing = parent_spacing |
| 537 | if style_raw is not None: |
| 538 | effective_spacing = float(parse_project_letter_spacing( |
| 539 | style_raw, |
| 540 | font_size=effective_font_sizes[id(elem)], |
| 541 | ).value) |
| 542 | elif direct_raw is not None: |
| 543 | effective_spacing = float(parse_project_letter_spacing( |
| 544 | direct_raw, |
| 545 | font_size=effective_font_sizes[id(elem)], |
| 546 | ).value) |
| 547 | resolved[id(elem)] = effective_spacing |
| 548 | for child in elem: |
| 549 | walk(child, effective_spacing) |
| 550 | |
| 551 | walk(root, 0.0) |
| 552 | return resolved |
| 553 | |
| 554 | |
| 555 | def materialize_project_text_metrics(root: ET.Element) -> int: |
| 556 | """Lower relative text metrics before positional tspan restructuring.""" |
| 557 | font_sizes = resolve_project_font_sizes(root) |
| 558 | letter_spacings = resolve_project_letter_spacings(root, font_sizes) |
| 559 | materialized = 0 |
| 560 | for elem in root.iter(): |
| 561 | canonical_font_size = _format_decimal(font_sizes[id(elem)]) |
| 562 | canonical_letter_spacing = _format_decimal(letter_spacings[id(elem)]) |
| 563 | if elem.get('font-size') is not None: |
| 564 | elem.set('font-size', canonical_font_size) |
| 565 | materialized += 1 |
| 566 | if elem.get('letter-spacing') is not None: |
| 567 | elem.set('letter-spacing', canonical_letter_spacing) |
| 568 | materialized += 1 |
| 569 | |
| 570 | style = elem.get('style') |
| 571 | if not style: |
| 572 | continue |
| 573 | retained: list[str] = [] |
| 574 | changed = False |
| 575 | for raw_fragment in style.split(';'): |
| 576 | fragment = raw_fragment.strip() |
| 577 | if not fragment: |
| 578 | continue |
| 579 | if ':' not in fragment: |
| 580 | retained.append(fragment) |
| 581 | continue |
| 582 | raw_name, _ = fragment.split(':', 1) |
| 583 | name = raw_name.strip().lower() |
| 584 | if name == 'font-size': |
| 585 | retained.append(f'font-size:{canonical_font_size}') |
| 586 | changed = True |
| 587 | materialized += 1 |
| 588 | elif name == 'letter-spacing': |
| 589 | retained.append( |
| 590 | f'letter-spacing:{canonical_letter_spacing}' |
| 591 | ) |
| 592 | changed = True |
| 593 | materialized += 1 |
| 594 | else: |
| 595 | retained.append(fragment) |
| 596 | if changed: |
| 597 | elem.set('style', '; '.join(retained)) |
| 598 | return materialized |
| 599 | |
| 600 | |
| 601 | def _diagnose_text_declaration( |
| 602 | elem: ET.Element, |
| 603 | *, |
| 604 | tag: str, |
| 605 | source: str, |
| 606 | name: str, |
| 607 | raw: str, |
| 608 | font_size: float, |
| 609 | ) -> tuple[bool, TextPropertyDiagnostic | None]: |
| 610 | """Return whether a declaration belongs to the text contract and its issue.""" |
| 611 | label = _element_label(elem) |
| 612 | if name == 'xml:space': |
| 613 | if source != 'attribute' or tag not in {'text', 'tspan'}: |
| 614 | return True, TextPropertyDiagnostic( |
| 615 | 'error', label, source, name, raw, |
| 616 | f'{label} can use xml:space only as a direct attribute on ' |
| 617 | '<text> or <tspan>', |
| 618 | ) |
| 619 | if raw not in _PROJECT_XML_SPACE_VALUES: |
| 620 | return True, TextPropertyDiagnostic( |
| 621 | 'error', label, source, name, raw, |
| 622 | f'{label} attribute xml:space={raw!r}: expected exactly ' |
| 623 | "'default' or 'preserve'", |
| 624 | ) |
| 625 | return True, None |
| 626 | if _is_unregistered_prefixed_text_property(name): |
| 627 | return True, TextPropertyDiagnostic( |
| 628 | 'error', label, source, name, raw, |
| 629 | f'{label} uses unregistered inherited text property {name!r}; ' |
| 630 | 'native PPTX export would ignore it', |
| 631 | ) |
| 632 | if ( |
| 633 | name in _TEXT_DECLARATION_PROPERTIES |
| 634 | and tag not in _TEXT_INHERITANCE_TARGETS |
| 635 | ): |
| 636 | return True, TextPropertyDiagnostic( |
| 637 | 'error', label, source, name, raw, |
| 638 | f'{label} cannot carry text property {name!r}; place it on ' |
| 639 | '<svg>, <g>, <text>, or <tspan>', |
| 640 | ) |
| 641 | if name in _UNSUPPORTED_TEXT_PROPERTIES: |
| 642 | return True, TextPropertyDiagnostic( |
| 643 | 'error', label, source, name, raw, |
| 644 | f'{label} uses unsupported text property {name!r}; ' |
| 645 | 'it has no registered DrawingML mapping', |
| 646 | ) |
| 647 | if name not in _SVG_TEXT_PROPERTIES: |
| 648 | return name in _TEXT_DECLARATION_PROPERTIES, None |
| 649 | if tag == 'tspan' and name == 'text-anchor': |
| 650 | return True, TextPropertyDiagnostic( |
| 651 | 'error', label, source, name, raw, |
| 652 | f'{label} cannot use text-anchor on <tspan>; place it on the ' |
| 653 | 'containing <text> or an ancestor group', |
| 654 | ) |
| 655 | try: |
| 656 | parsed = parse_project_text_property( |
| 657 | name, |
| 658 | raw, |
| 659 | font_size=font_size, |
| 660 | ) |
| 661 | except ValueError as exc: |
| 662 | return True, TextPropertyDiagnostic( |
| 663 | 'error', label, source, name, raw, |
| 664 | f'{label} {source} {name}={raw!r}: {exc}', |
| 665 | ) |
| 666 | if parsed.compatible: |
| 667 | return True, TextPropertyDiagnostic( |
| 668 | 'warning', label, source, name, raw, |
| 669 | f'{label} {source} {name}={raw!r} is compatible; ' |
| 670 | f'prefer {name}={parsed.canonical!r}', |
| 671 | parsed.canonical, |
| 672 | ) |
| 673 | return True, None |
| 674 | |
| 675 | |
| 676 | def project_text_property_diagnostics( |
| 677 | root: ET.Element, |
| 678 | ) -> list[TextPropertyDiagnostic]: |
| 679 | """Validate the closed text attribute/value surface for one SVG tree.""" |
| 680 | font_sizes, diagnostics = _resolve_font_sizes(root) |
| 681 | |
| 682 | for elem in root.iter(): |
| 683 | tag = _local_name(elem.tag) |
| 684 | label = _element_label(elem) |
| 685 | direct_allowlist = { |
| 686 | 'text': _TEXT_DIRECT_ATTRIBUTES, |
| 687 | 'tspan': _TSPAN_DIRECT_ATTRIBUTES, |
| 688 | }.get(tag) |
| 689 | inline_allowlist = { |
| 690 | 'text': _TEXT_INLINE_PROPERTIES, |
| 691 | 'tspan': _TSPAN_INLINE_PROPERTIES, |
| 692 | }.get(tag) |
| 693 | |
| 694 | for raw_name, raw in elem.attrib.items(): |
| 695 | name = _attribute_name(raw_name) |
| 696 | if name == 'style' or name.startswith('data-'): |
| 697 | continue |
| 698 | handled, diagnostic = _diagnose_text_declaration( |
| 699 | elem, |
| 700 | tag=tag, |
| 701 | source='attribute', |
| 702 | name=name, |
| 703 | raw=raw, |
| 704 | font_size=font_sizes[id(elem)], |
| 705 | ) |
| 706 | if diagnostic is not None: |
| 707 | diagnostics.append(diagnostic) |
| 708 | elif ( |
| 709 | not handled |
| 710 | and direct_allowlist is not None |
| 711 | and name not in direct_allowlist |
| 712 | ): |
| 713 | diagnostics.append(TextPropertyDiagnostic( |
| 714 | 'error', label, 'attribute', name, raw, |
| 715 | f'{label} uses unsupported text attribute {name!r}; ' |
| 716 | 'native PPTX export would ignore it', |
| 717 | )) |
| 718 | |
| 719 | declarations, malformed = _iter_style_declarations(elem) |
| 720 | for fragment in malformed: |
| 721 | property_hint = fragment.split(None, 1)[0].lower() |
| 722 | if ( |
| 723 | inline_allowlist is not None |
| 724 | or property_hint in _TEXT_DECLARATION_PROPERTIES |
| 725 | or property_hint in _UNSUPPORTED_TEXT_PROPERTIES |
| 726 | or _is_unregistered_prefixed_text_property(property_hint) |
| 727 | ): |
| 728 | diagnostics.append(TextPropertyDiagnostic( |
| 729 | 'error', label, 'inline style', '<malformed>', fragment, |
| 730 | f'{label} has malformed inline style declaration {fragment!r}', |
| 731 | )) |
| 732 | for name, raw in declarations: |
| 733 | handled, diagnostic = _diagnose_text_declaration( |
| 734 | elem, |
| 735 | tag=tag, |
| 736 | source='inline style', |
| 737 | name=name, |
| 738 | raw=raw, |
| 739 | font_size=font_sizes[id(elem)], |
| 740 | ) |
| 741 | if diagnostic is not None: |
| 742 | diagnostics.append(diagnostic) |
| 743 | elif ( |
| 744 | not handled |
| 745 | and inline_allowlist is not None |
| 746 | and name not in inline_allowlist |
| 747 | ): |
| 748 | diagnostics.append(TextPropertyDiagnostic( |
| 749 | 'error', label, 'inline style', name, raw, |
| 750 | f'{label} uses unsupported inline text property {name!r}; ' |
| 751 | 'native PPTX export would ignore it', |
| 752 | )) |
| 753 | |
| 754 | return diagnostics |
| 755 | |
| 756 | |
| 757 | def project_text_property_errors(root: ET.Element) -> list[str]: |
| 758 | """Return blocking diagnostics for the converter preflight.""" |
| 759 | return [ |
| 760 | diagnostic.message |
| 761 | for diagnostic in project_text_property_diagnostics(root) |
| 762 | if diagnostic.severity == 'error' |
| 763 | ] |
| 764 |