返回 ppt-master
text_properties.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / drawingml / text_properties.py
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
764 lines PYTHON