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