返回 ppt-master
utils.py
1 """Coordinate, transform, color, and font helpers for DrawingML conversion.
2
3 See references/shared-standards-core.md §2.1 for project geometry and
4 references/svg-effects.md §§6.2–6.8 for paint, image-fit, line-presentation,
5 and transform authoring contracts.
6 """
7
8 from __future__ import annotations
9
10 import colorsys
11 import math
12 import re
13 import unicodedata
14 from collections import Counter
15 from collections.abc import Iterator
16 from decimal import Decimal, ROUND_HALF_UP
17 from xml.etree import ElementTree as ET
18
19 from pptx_shapes import (
20 OOXML_COORDINATE_MAX,
21 resolve_preset_preview_hash,
22 svg_preset_preview_fingerprint,
23 validate_ooxml_xfrm,
24 )
25 from language_tags import language_base, language_uses_rtl
26
27 from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
28
29 # ---------------------------------------------------------------------------
30 # Constants
31 # ---------------------------------------------------------------------------
32
33 SVG_NS = 'http://www.w3.org/2000/svg'
34 XLINK_NS = 'http://www.w3.org/1999/xlink'
35
36 EMU_PER_PX = 9525 # 1 SVG px = 9525 EMU (96 DPI)
37 FONT_PX_TO_HUNDREDTHS_PT = 75 # 1px = 0.75pt -> 75 hundredths-of-a-point
38 DRAWINGML_TEXT_FONT_SIZE_MIN = 100
39 DRAWINGML_TEXT_FONT_SIZE_MAX = 400_000
40 ANGLE_UNIT = 60000 # DrawingML angle: 60000ths of a degree
41
42 # SVG attributes inheritable from parent <g>
43 INHERITABLE_ATTRS = [
44 'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap',
45 'stroke-linejoin', 'fill-opacity', 'stroke-opacity',
46 'font-family', 'font-size', 'font-weight', 'font-style',
47 'text-anchor', 'letter-spacing', 'text-decoration',
48 ]
49
50 # Known East Asian fonts
51 EA_FONTS = {
52 'PingFang SC', 'PingFang TC', 'PingFang HK',
53 'Microsoft YaHei', 'Microsoft JhengHei',
54 'SimSun', 'SimHei', 'FangSong', 'KaiTi', 'STKaiti',
55 'STHeiti', 'STSong', 'STFangsong', 'STXihei', 'STZhongsong',
56 'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Mincho ProN',
57 'Hiragino Kaku Gothic ProN', 'Hiragino Kaku Gothic Pro',
58 'Hiragino Mincho Pro',
59 'Noto Sans SC', 'Noto Sans TC', 'Noto Serif SC', 'Noto Serif TC',
60 'Noto Sans CJK SC', 'Noto Serif CJK SC',
61 'Noto Sans JP', 'Noto Serif JP', 'Noto Sans CJK JP',
62 'Source Han Sans SC', 'Source Han Sans TC',
63 'Source Han Serif SC', 'Source Han Serif TC',
64 'Source Han Sans JP', 'Source Han Serif JP',
65 'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei',
66 'YouYuan', 'LiSu', 'HuaWenKaiTi',
67 'Heiti TC', 'Kaiti TC', 'Songti SC', 'Songti TC',
68 # Windows 10/11 + Office default / common Simplified Chinese
69 'DengXian', 'DengXian Light', 'DengXian Bold', 'Microsoft YaHei UI',
70 # Office display Chinese (华文 / 方正) — usually title-only, not on every client
71 'STXingkai', 'STLiti', 'STXinwei', 'STHupo', 'STCaiyun',
72 'FZShuTi', 'FZYaoti',
73 # Common Traditional Chinese (Office)
74 'DFKai-SB', 'MingLiU', 'PMingLiU', 'MingLiU_HKSCS',
75 'MingLiU-ExtB', 'PMingLiU-ExtB',
76 'Microsoft JhengHei UI',
77 # Japanese fonts (Windows-available)
78 'Yu Gothic', 'Yu Gothic UI', 'Yu Mincho',
79 'Meiryo', 'Meiryo UI', 'メイリオ',
80 'MS Gothic', 'MS Mincho', 'MS PGothic', 'MS PMincho', 'MS UI Gothic',
81 # Korean
82 'Malgun Gothic', 'Gulim', 'Dotum', 'Batang',
83 'Noto Sans KR', 'Noto Serif KR',
84 }
85 SYSTEM_FONTS = {'system-ui', '-apple-system', 'BlinkMacSystemFont'}
86
87 # macOS/Linux-only fonts -> Windows equivalents
88 FONT_FALLBACK_WIN = {
89 'PingFang SC': 'Microsoft YaHei',
90 'PingFang TC': 'Microsoft JhengHei',
91 'PingFang HK': 'Microsoft JhengHei',
92 'Heiti TC': 'Microsoft JhengHei',
93 'Kaiti TC': 'DFKai-SB',
94 'Hiragino Sans': 'Microsoft YaHei',
95 'Hiragino Sans GB': 'Microsoft YaHei',
96 'Hiragino Mincho ProN': 'SimSun',
97 'STHeiti': 'SimHei',
98 'STSong': 'SimSun',
99 'STKaiti': 'KaiTi',
100 'STFangsong': 'FangSong',
101 'STXihei': 'Microsoft YaHei',
102 'STZhongsong': 'SimSun',
103 'Songti SC': 'SimSun',
104 'Songti TC': 'PMingLiU',
105 'Noto Sans SC': 'Microsoft YaHei',
106 'Noto Sans CJK SC': 'Microsoft YaHei',
107 'Noto Sans TC': 'Microsoft JhengHei',
108 'Noto Serif SC': 'SimSun',
109 'Noto Serif CJK SC': 'SimSun',
110 'Noto Serif TC': 'PMingLiU',
111 # Japanese: keep as-is if user specified (PowerPoint will fallback if uninstalled)
112 # 'Noto Sans JP': → keep as 'Noto Sans JP' (do not map)
113 # 'メイリオ': → keep as 'メイリオ' (Meiryo alias)
114 'メイリオ': 'Meiryo',
115 'Source Han Sans SC': 'Microsoft YaHei',
116 'Source Han Sans TC': 'Microsoft JhengHei',
117 'Source Han Serif SC': 'SimSun',
118 'Source Han Serif TC': 'PMingLiU',
119 'Source Han Sans JP': 'Noto Sans JP',
120 'Source Han Serif JP': 'Noto Serif JP',
121 'WenQuanYi Micro Hei': 'Microsoft YaHei',
122 'WenQuanYi Zen Hei': 'Microsoft YaHei',
123 # Latin fonts (macOS / Linux / Web -> Windows)
124 'SF Pro': 'Segoe UI',
125 'SF Pro Display': 'Segoe UI',
126 'SF Pro Text': 'Segoe UI',
127 'SF Mono': 'Consolas',
128 'Menlo': 'Consolas',
129 'Monaco': 'Consolas',
130 'Helvetica Neue': 'Arial',
131 'Helvetica': 'Arial',
132 'Roboto': 'Segoe UI',
133 'Ubuntu': 'Segoe UI',
134 'Liberation Sans': 'Arial',
135 'Liberation Serif': 'Times New Roman',
136 'Liberation Mono': 'Consolas',
137 'DejaVu Sans': 'Segoe UI',
138 'DejaVu Serif': 'Times New Roman',
139 'DejaVu Sans Mono': 'Consolas',
140 }
141
142 GENERIC_FONT_MAP = {
143 'monospace': 'Consolas',
144 'sans-serif': 'Segoe UI',
145 'serif': 'Times New Roman',
146 }
147
148 # When the latin font is serif and no EA font is specified,
149 # prefer SimSun (serif CJK) over Microsoft YaHei (sans-serif CJK).
150 _SERIF_LATIN = {
151 'Times New Roman', 'Georgia', 'Garamond', 'Palatino', 'Palatino Linotype',
152 'Book Antiqua', 'Cambria', 'SimSun', 'Liberation Serif', 'DejaVu Serif',
153 }
154
155 # Common Office/OS faces accepted without a custom-font warning on their
156 # corresponding target locale. Actual playback availability remains
157 # target-specific; keep these examples aligned with strategist.md §g.
158 PPT_SAFE_FONTS = frozenset({
159 'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong',
160 'dengxian',
161 'microsoft jhenghei', 'microsoft jhenghei ui', 'pmingliu', 'mingliu',
162 'mingliu_hkscs', 'dfkai-sb',
163 'pingfang sc', 'heiti sc', 'songti sc', 'stsong',
164 'pingfang tc', 'pingfang hk', 'heiti tc', 'songti tc', 'kaiti tc',
165 'yu gothic', 'yu gothic ui', 'yu mincho',
166 'meiryo', 'meiryo ui',
167 'ms gothic', 'ms mincho', 'ms pgothic', 'ms pmincho', 'ms ui gothic',
168 'malgun gothic', 'gulim', 'dotum', 'batang',
169 'arial', 'arial black', 'calibri', 'segoe ui', 'verdana',
170 'helvetica', 'helvetica neue', 'tahoma', 'trebuchet ms',
171 'times new roman', 'times', 'georgia', 'cambria', 'palatino',
172 'garamond', 'book antiqua',
173 'consolas', 'courier new', 'menlo', 'monaco',
174 'impact',
175 })
176
177 # Parsed SVG stroke-dasharray values -> DrawingML prstDash
178 DASH_PRESETS = {
179 (4.0, 4.0): 'dash',
180 (6.0, 3.0): 'dash',
181 (2.0, 2.0): 'sysDot',
182 (8.0, 4.0): 'lgDash',
183 (8.0, 4.0, 2.0, 4.0): 'lgDashDot',
184 }
185 PROJECT_STROKE_ENUM_VALUES = {
186 'stroke-linecap': frozenset({'butt', 'round', 'square'}),
187 'stroke-linejoin': frozenset({'bevel', 'miter', 'round'}),
188 'vector-effect': frozenset({'none', 'non-scaling-stroke'}),
189 }
190 PROJECT_IMAGE_ASPECT_RATIO_ANCHORS = {
191 'xMinYMin': (0.0, 0.0),
192 'xMidYMin': (0.5, 0.0),
193 'xMaxYMin': (1.0, 0.0),
194 'xMinYMid': (0.0, 0.5),
195 'xMidYMid': (0.5, 0.5),
196 'xMaxYMid': (1.0, 0.5),
197 'xMinYMax': (0.0, 1.0),
198 'xMidYMax': (0.5, 1.0),
199 'xMaxYMax': (1.0, 1.0),
200 }
201 PROJECT_IMAGE_ASPECT_RATIO_MODES = frozenset({'meet', 'slice'})
202 PROJECT_OPACITY_PROPERTIES = (
203 'opacity',
204 'fill-opacity',
205 'stroke-opacity',
206 'stop-opacity',
207 'flood-opacity',
208 )
209 PROJECT_PERCENTAGE_OPACITY_PROPERTIES = frozenset({
210 'stop-opacity',
211 'flood-opacity',
212 })
213 PROJECT_PAINT_PROPERTIES = (
214 'fill',
215 'stroke',
216 'stop-color',
217 'flood-color',
218 'data-pptx-fg',
219 'data-pptx-bg',
220 )
221 PROJECT_REFERENCE_PAINT_PROPERTIES = frozenset({'fill', 'stroke'})
222 PROJECT_DEFINITION_TAGS = frozenset({
223 'clipPath',
224 'filter',
225 'linearGradient',
226 'marker',
227 'pattern',
228 'radialGradient',
229 })
230 PROJECT_GRADIENT_TAGS = frozenset({'linearGradient', 'radialGradient'})
231 PROJECT_TEXT_IMAGE_FILL_ATTR = 'data-pptx-text-image-fill'
232 PROJECT_TEXT_IMAGE_FILL_MODES = frozenset({'stretch', 'tile'})
233 # PPTX angle projection can overshoot a unit box by at most ~0.1036.
234 PROJECT_LINEAR_GRADIENT_COORDINATE_MIN = -0.105
235 PROJECT_LINEAR_GRADIENT_COORDINATE_MAX = 1.105
236 PROJECT_RADIAL_FOCUS_TOLERANCE = 0.00001
237 PROJECT_FILTER_PRIMITIVES = frozenset({
238 'feDropShadow',
239 'feGaussianBlur',
240 'feOffset',
241 'feFlood',
242 'feComposite',
243 'feMerge',
244 'feMergeNode',
245 'feComponentTransfer',
246 'feFuncA',
247 })
248 PROJECT_FILTER_EFFECT_PRIMITIVES = frozenset({
249 'feDropShadow',
250 'feGaussianBlur',
251 })
252 PROJECT_FILTER_PUBLIC_TARGETS = frozenset({
253 'rect',
254 'circle',
255 'image',
256 'path',
257 'text',
258 })
259 _PROJECT_MARKER_NUMBER_TOKEN = (
260 r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
261 )
262 _PROJECT_MARKER_POINT_TOKEN = (
263 rf'{_PROJECT_MARKER_NUMBER_TOKEN}'
264 rf'(?:\s*,\s*|\s+){_PROJECT_MARKER_NUMBER_TOKEN}'
265 )
266 _PROJECT_MARKER_TRIANGLE_PATH_RE = re.compile(
267 rf'^\s*M\s*{_PROJECT_MARKER_POINT_TOKEN}'
268 rf'(?:\s*L\s*{_PROJECT_MARKER_POINT_TOKEN}){{2}}\s*Z\s*$',
269 re.IGNORECASE,
270 )
271 _PROJECT_MARKER_DIAMOND_PATH_RE = re.compile(
272 rf'^\s*M\s*{_PROJECT_MARKER_POINT_TOKEN}'
273 rf'(?:\s*L\s*{_PROJECT_MARKER_POINT_TOKEN}){{3}}\s*Z\s*$',
274 re.IGNORECASE,
275 )
276 _PROJECT_MARKER_ARROW_PATH_RE = re.compile(
277 rf'^\s*M\s*{_PROJECT_MARKER_POINT_TOKEN}'
278 rf'(?:\s*L\s*{_PROJECT_MARKER_POINT_TOKEN}){{2}}\s*$',
279 re.IGNORECASE,
280 )
281 _PROJECT_MARKER_COMMAND_POINT_RE = re.compile(
282 rf'[ML]\s*({_PROJECT_MARKER_NUMBER_TOKEN})'
283 rf'(?:\s*,\s*|\s+)({_PROJECT_MARKER_NUMBER_TOKEN})',
284 re.IGNORECASE,
285 )
286 PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS = frozenset({
287 'defs',
288 'desc',
289 'metadata',
290 'style',
291 'title',
292 })
293 THICK_CIRCLE_COVERAGE_TOLERANCE = 1.0
294
295
296 # ---------------------------------------------------------------------------
297 # Coordinate helpers
298 # ---------------------------------------------------------------------------
299
300 def px_to_emu(px: float) -> int:
301 """Convert SVG pixels to EMU."""
302 return round(px * EMU_PER_PX)
303
304
305 def font_px_to_hpt(font_size_px: float) -> int:
306 """Convert one legal SVG font size to DrawingML hundredths-of-a-point."""
307 try:
308 px = float(font_size_px)
309 except (TypeError, ValueError, OverflowError) as exc:
310 raise ValueError(
311 f"SVG font-size must be numeric, got {font_size_px!r}"
312 ) from exc
313 scaled = px * FONT_PX_TO_HUNDREDTHS_PT
314 if not math.isfinite(scaled):
315 raise ValueError(f"SVG font-size must be finite, got {font_size_px!r}")
316 size = int(round(scaled / 10.0)) * 10
317 if not DRAWINGML_TEXT_FONT_SIZE_MIN <= size <= DRAWINGML_TEXT_FONT_SIZE_MAX:
318 raise ValueError(
319 f"SVG font-size {font_size_px!r}px converts to DrawingML sz={size}; "
320 f"expected {DRAWINGML_TEXT_FONT_SIZE_MIN}.."
321 f"{DRAWINGML_TEXT_FONT_SIZE_MAX} (1..4000pt)"
322 )
323 return size
324
325
326 def _f(val: str | None, default: float = 0.0) -> float:
327 """Parse a float attribute value, returning default if missing."""
328 if val is None:
329 return default
330 try:
331 return float(val)
332 except (ValueError, TypeError):
333 return default
334
335
336 _LENGTH_RE = re.compile(r'^\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)\s*([A-Za-z%]*)\s*$')
337 _CANONICAL_PROJECT_GEOMETRY_LENGTH_RE = re.compile(
338 r'^-?(?:\d+(?:\.\d+)?|\.\d+)$'
339 )
340 _PROJECT_STROKE_DASH_NUMBER_PATTERN = (
341 r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?'
342 )
343 _PROJECT_STROKE_DASH_NUMBER_RE = re.compile(
344 _PROJECT_STROKE_DASH_NUMBER_PATTERN
345 )
346 _PROJECT_STROKE_DASHARRAY_RE = re.compile(
347 rf'\s*{_PROJECT_STROKE_DASH_NUMBER_PATTERN}'
348 rf'(?:(?:\s+|\s*,\s*){_PROJECT_STROKE_DASH_NUMBER_PATTERN})+\s*'
349 )
350 PROJECT_GEOMETRY_LENGTH_ATTRIBUTES = {
351 'svg': frozenset({'x', 'y', 'width', 'height'}),
352 'rect': frozenset({'x', 'y', 'width', 'height', 'rx', 'ry'}),
353 'circle': frozenset({'cx', 'cy', 'r'}),
354 'ellipse': frozenset({'cx', 'cy', 'rx', 'ry'}),
355 'line': frozenset({'x1', 'y1', 'x2', 'y2'}),
356 'text': frozenset({'x', 'y'}),
357 'tspan': frozenset({'x', 'y', 'dx', 'dy'}),
358 'image': frozenset({'x', 'y', 'width', 'height'}),
359 'use': frozenset({'x', 'y', 'width', 'height'}),
360 }
361 PROJECT_NON_NEGATIVE_LENGTH_ATTRIBUTES = frozenset({
362 'width', 'height', 'r', 'rx', 'ry', 'stroke-width',
363 })
364
365
366 def _parse_svg_length_parts(val: str) -> tuple[float, str]:
367 """Parse one finite SVG length into its numeric part and lowercase unit."""
368 match = _LENGTH_RE.match(str(val))
369 if not match:
370 raise ValueError(f'SVG length must be one finite literal, got {val!r}')
371 number = float(match.group(1))
372 if not math.isfinite(number):
373 raise ValueError(f'SVG length must be finite, got {val!r}')
374 return number, match.group(2).lower()
375
376
377 def parse_svg_length(
378 val: str | None,
379 default: float = 0.0,
380 *,
381 percent_base: float | None = None,
382 font_size: float = 16.0,
383 ) -> float:
384 """Parse SVG/CSS length values into SVG px.
385
386 Unitless and ``px`` values are already SVG px. Percentages need a caller
387 supplied reference length because SVG uses different bases for x, y,
388 width, height, and radii.
389
390 A default applies only when the attribute is absent. Present but malformed,
391 non-finite, unsupported, or context-free percentage values fail closed.
392 """
393 if val is None:
394 return default
395 number, unit = _parse_svg_length_parts(str(val))
396 if unit == '%':
397 if percent_base is None:
398 raise ValueError(
399 f'SVG percentage length requires a reference length, got {val!r}'
400 )
401 return percent_base * number / 100.0
402 if unit in ('', 'px'):
403 return number
404 if unit == 'pt':
405 return number * 96.0 / 72.0
406 if unit in ('pc', 'pica'):
407 return number * 16.0
408 if unit == 'in':
409 return number * 96.0
410 if unit == 'cm':
411 return number * 96.0 / 2.54
412 if unit == 'mm':
413 return number * 96.0 / 25.4
414 if unit == 'q':
415 return number * 96.0 / 101.6
416 if unit in ('em', 'rem'):
417 if not math.isfinite(font_size):
418 raise ValueError(
419 f'SVG relative length requires a finite font size, got {val!r}'
420 )
421 return number * font_size
422 raise ValueError(f'Unsupported SVG length unit {unit!r} in {val!r}')
423
424
425 def parse_project_geometry_length(raw: str, attribute: str) -> float:
426 """Parse one project geometry value without widening the authoring surface."""
427 number, unit = _parse_svg_length_parts(raw)
428 if unit not in {'', 'px'}:
429 raise ValueError(
430 f'uses unsupported unit {unit!r}; project geometry accepts only '
431 'unitless values or the compatible px suffix'
432 )
433 numeric_literal = raw.strip()
434 if unit == 'px':
435 numeric_literal = numeric_literal[:-2].strip()
436 if not _CANONICAL_PROJECT_GEOMETRY_LENGTH_RE.fullmatch(numeric_literal):
437 raise ValueError(
438 'uses an unsupported numeric spelling; use an ordinary decimal '
439 'without a leading plus sign, exponent, or trailing decimal point'
440 )
441 if attribute in PROJECT_NON_NEGATIVE_LENGTH_ATTRIBUTES and number < 0:
442 raise ValueError('must be non-negative')
443 return number
444
445
446 def is_canonical_project_geometry_length(raw: str) -> bool:
447 """Return whether a project geometry value uses the generated-SVG spelling."""
448 return bool(_CANONICAL_PROJECT_GEOMETRY_LENGTH_RE.fullmatch(raw.strip()))
449
450
451 def format_project_geometry_length(value: float) -> str:
452 """Format a parsed project geometry value as a plain unitless decimal."""
453 if abs(value) < 1e-15:
454 return '0'
455 text = f'{value:.15f}'.rstrip('0').rstrip('.')
456 return '0' if text in {'', '-0'} else text
457
458
459 def parse_project_opacity(
460 raw: str,
461 *,
462 allow_percentage: bool = False,
463 ) -> float:
464 """Parse and clamp one opacity value from the closed project grammar."""
465 try:
466 number, unit = _parse_svg_length_parts(raw)
467 except ValueError as exc:
468 raise ValueError('must be one finite numeric opacity') from exc
469
470 if unit == '%':
471 if not allow_percentage:
472 raise ValueError('must be unitless; percentages are not supported')
473 number /= 100.0
474 elif unit:
475 raise ValueError(f'uses unsupported unit {unit!r}')
476 return max(0.0, min(1.0, number))
477
478
479 def is_project_opacity_default_form(raw: str) -> bool:
480 """Return whether opacity uses the generated finite unitless ``0..1`` form."""
481 try:
482 number, unit = _parse_svg_length_parts(raw)
483 except ValueError:
484 return False
485 return unit == '' and 0.0 <= number <= 1.0
486
487
488 def format_project_opacity(value: float) -> str:
489 """Format one parsed opacity as a compact unitless ``0..1`` value."""
490 bounded = max(0.0, min(1.0, value))
491 return f'{bounded:.6f}'.rstrip('0').rstrip('.') or '0'
492
493
494 def parse_project_image_aspect_ratio(raw: str | None) -> tuple[str, str]:
495 """Parse the closed project ``<image>`` aspect-ratio grammar."""
496 if raw is None:
497 return 'xMidYMid', 'meet'
498
499 text = raw.strip()
500 if not text:
501 raise ValueError('must not be empty; omit the attribute for the default')
502
503 parts = text.split()
504 align = parts[0]
505 if align == 'none':
506 if len(parts) != 1:
507 raise ValueError('value "none" must appear alone')
508 return align, 'meet'
509
510 if align not in PROJECT_IMAGE_ASPECT_RATIO_ANCHORS:
511 choices = ', '.join(PROJECT_IMAGE_ASPECT_RATIO_ANCHORS)
512 raise ValueError(
513 f'alignment must be "none" or one of: {choices}'
514 )
515 if len(parts) > 2:
516 raise ValueError('accepts at most one alignment and one mode token')
517
518 mode = parts[1] if len(parts) == 2 else 'meet'
519 if mode not in PROJECT_IMAGE_ASPECT_RATIO_MODES:
520 choices = ', '.join(sorted(PROJECT_IMAGE_ASPECT_RATIO_MODES))
521 raise ValueError(f'mode must be one of: {choices}')
522 return align, mode
523
524
525 def format_project_image_aspect_ratio(align: str, mode: str) -> str:
526 """Format one parsed image aspect ratio for generated project SVG."""
527 if align == 'none':
528 return 'none'
529 return f'{align} {mode}'
530
531
532 def _parse_project_stroke_dasharray(
533 raw: str,
534 *,
535 allow_zero_gap: bool = False,
536 ) -> tuple[str | None, tuple[float, ...], tuple[str, ...]] | None:
537 """Parse one project dash array without accepting general SVG lengths."""
538 text = raw.strip()
539 if text == 'none':
540 return None
541 if not _PROJECT_STROKE_DASHARRAY_RE.fullmatch(text):
542 raise ValueError(
543 'must be "none" or at least two finite unitless numbers separated '
544 'by spaces or single commas'
545 )
546 tokens = tuple(_PROJECT_STROKE_DASH_NUMBER_RE.findall(text))
547 values = tuple(float(token) for token in tokens)
548 if not all(math.isfinite(value) for value in values):
549 raise ValueError('must contain only finite numbers')
550 if values[0] <= 0:
551 raise ValueError('dash length must be positive')
552 if allow_zero_gap:
553 if values[1] < 0:
554 raise ValueError('dash gap must be non-negative')
555 elif values[1] <= 0:
556 raise ValueError('dash gap must be positive')
557 if any(value <= 0 for value in values[2:]):
558 raise ValueError('additional dash and gap values must be positive')
559 return DASH_PRESETS.get(values), values, tokens
560
561
562 def parse_project_stroke_dasharray(
563 raw: str,
564 *,
565 allow_zero_gap: bool = False,
566 ) -> tuple[str | None, tuple[float, ...]] | None:
567 """Return the registered preset and numeric values for one dash array."""
568 parsed = _parse_project_stroke_dasharray(
569 raw,
570 allow_zero_gap=allow_zero_gap,
571 )
572 if parsed is None:
573 return None
574 preset, values, _tokens = parsed
575 return preset, values
576
577
578 def noncanonical_stroke_dash_numbers(raw: str) -> tuple[str, ...]:
579 """Return compatible dash numbers outside the generated-SVG spelling."""
580 parsed = _parse_project_stroke_dasharray(raw, allow_zero_gap=True)
581 if parsed is None:
582 return ()
583 _preset, _values, tokens = parsed
584 return tuple(
585 token
586 for token in tokens
587 if not _CANONICAL_PROJECT_GEOMETRY_LENGTH_RE.fullmatch(token)
588 )
589
590
591 def parse_project_stroke_enum(attribute: str, raw: str) -> str:
592 """Parse one closed line-presentation enumeration."""
593 allowed = PROJECT_STROKE_ENUM_VALUES.get(attribute)
594 if allowed is None:
595 raise ValueError(f'has no registered project enumeration for {attribute!r}')
596 value = raw.strip()
597 if value not in allowed:
598 choices = ', '.join(sorted(allowed))
599 raise ValueError(f'must be one of: {choices}')
600 return value
601
602
603 def is_thick_circle_shorthand(
604 dasharray: str | None,
605 stroke: str | None,
606 fill: str | None,
607 stroke_width: float,
608 radius: float,
609 ) -> bool:
610 """Return whether one circle uses the converter's thick-arc shorthand."""
611 if (
612 not dasharray
613 or not stroke
614 or stroke.strip().lower() in {'none', 'transparent'}
615 ):
616 return False
617 if not fill or fill.strip().lower() != 'none':
618 return False
619 if stroke_width <= 0 or radius <= 0 or stroke_width >= 2 * radius:
620 return False
621 if stroke_width / radius < 0.15:
622 return False
623 try:
624 parsed = parse_project_stroke_dasharray(
625 dasharray,
626 allow_zero_gap=True,
627 )
628 except ValueError:
629 return False
630 if parsed is None:
631 return False
632 preset, values = parsed
633 if preset is not None or len(values) != 2:
634 return False
635 dash, gap = values
636 circumference = 2 * math.pi * radius
637 return (
638 dash < circumference
639 and dash + gap + THICK_CIRCLE_COVERAGE_TOLERANCE >= circumference
640 )
641
642
643 def svg_length_x(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
644 return parse_svg_length(val, default, percent_base=ctx.viewport_width)
645
646
647 def svg_length_y(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
648 return parse_svg_length(val, default, percent_base=ctx.viewport_height)
649
650
651 def svg_length_size(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
652 base = min(ctx.viewport_width, ctx.viewport_height)
653 return parse_svg_length(val, default, percent_base=base)
654
655
656 # ---------------------------------------------------------------------------
657 # SVG transform matrix helpers
658 # ---------------------------------------------------------------------------
659
660 _TRANSFORM_NUMBER_PATTERN = (
661 r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?'
662 )
663 _TRANSFORM_NUMBER_RE = re.compile(_TRANSFORM_NUMBER_PATTERN)
664 _CANONICAL_TRANSFORM_NUMBER_RE = re.compile(
665 r'-?(?:\d+(?:\.\d+)?|\.\d+)$'
666 )
667 _TRANSFORM_OPERATION_RE = re.compile(r'([A-Za-z]+)\(([^()]*)\)')
668 _TRANSFORM_WHITESPACE_RE = re.compile(r'[ \t\r\n]*')
669 _TRANSFORM_SEPARATOR_PATTERN = (
670 r'(?:[ \t\r\n]+|[ \t\r\n]*,[ \t\r\n]*)'
671 )
672 _TRANSFORM_SEPARATOR_RE = re.compile(_TRANSFORM_SEPARATOR_PATTERN)
673 _TRANSFORM_ARGUMENTS_RE = re.compile(
674 rf'[ \t\r\n]*{_TRANSFORM_NUMBER_PATTERN}'
675 rf'(?:{_TRANSFORM_SEPARATOR_PATTERN}{_TRANSFORM_NUMBER_PATTERN})*'
676 r'[ \t\r\n]*'
677 )
678 _TRANSFORM_ARITIES = {
679 'matrix': frozenset({6}),
680 'translate': frozenset({1, 2}),
681 'scale': frozenset({1, 2}),
682 'rotate': frozenset({1, 3}),
683 }
684 _FULL_TRANSFORM_TAGS = frozenset({
685 'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline',
686 'image',
687 })
688 _TRANSFORM_CONTAINER_TAGS = frozenset({'g', 'use'})
689 _TRANSFORM_DEFINITION_BOUNDARIES = frozenset({'clipPath', 'marker', 'pattern'})
690 _NON_VISUAL_TRANSFORM_CHILD_TAGS = frozenset({
691 'defs', 'title', 'desc', 'metadata', 'style',
692 })
693
694
695 def matrix_multiply(left: AffineMatrix, right: AffineMatrix) -> AffineMatrix:
696 """Compose two SVG affine matrices, applying ``right`` before ``left``."""
697 a1, b1, c1, d1, e1, f1 = left
698 a2, b2, c2, d2, e2, f2 = right
699 return (
700 a1 * a2 + c1 * b2,
701 b1 * a2 + d1 * b2,
702 a1 * c2 + c1 * d2,
703 b1 * c2 + d1 * d2,
704 a1 * e2 + c1 * f2 + e1,
705 b1 * e2 + d1 * f2 + f1,
706 )
707
708
709 def _translate_matrix(tx: float, ty: float = 0.0) -> AffineMatrix:
710 return (1.0, 0.0, 0.0, 1.0, tx, ty)
711
712
713 def _scale_matrix(sx: float, sy: float | None = None) -> AffineMatrix:
714 return (sx, 0.0, 0.0, sx if sy is None else sy, 0.0, 0.0)
715
716
717 def _rotate_matrix(angle_deg: float, cx: float | None = None, cy: float | None = None) -> AffineMatrix:
718 rad = math.radians(angle_deg)
719 cos_a = math.cos(rad)
720 sin_a = math.sin(rad)
721 rot = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
722 if cx is None or cy is None:
723 return rot
724 return matrix_multiply(
725 matrix_multiply(_translate_matrix(cx, cy), rot),
726 _translate_matrix(-cx, -cy),
727 )
728
729
730 def _parse_transform_operations(
731 transform_str: str,
732 ) -> tuple[
733 tuple[tuple[str, tuple[float, ...]], ...],
734 tuple[str, ...],
735 ]:
736 """Parse a complete project transform list and retain numeric tokens."""
737 if not transform_str:
738 return (), ()
739
740 operations: list[tuple[str, tuple[float, ...]]] = []
741 number_tokens: list[str] = []
742 cursor = 0
743 matches = list(_TRANSFORM_OPERATION_RE.finditer(transform_str))
744 if not matches:
745 if _TRANSFORM_WHITESPACE_RE.fullmatch(transform_str):
746 raise ValueError('SVG transform must not be empty')
747 raise ValueError(f'Invalid SVG transform syntax {transform_str!r}')
748
749 for index, match in enumerate(matches):
750 gap = transform_str[cursor:match.start()]
751 gap_pattern = (
752 _TRANSFORM_WHITESPACE_RE
753 if index == 0 else _TRANSFORM_SEPARATOR_RE
754 )
755 if gap_pattern.fullmatch(gap) is None:
756 if index > 0 and not gap:
757 raise ValueError(
758 f'SVG transform operations require a separator at '
759 f'offset {cursor}'
760 )
761 raise ValueError(
762 f'Invalid SVG transform syntax at offset {cursor}: '
763 f'{gap!r}'
764 )
765 name, raw_args = match.groups()
766 if name not in _TRANSFORM_ARITIES:
767 raise ValueError(
768 f'Unsupported SVG transform operation {name!r}; use lowercase '
769 'matrix, translate, scale, or rotate'
770 )
771 if raw_args.strip() and _TRANSFORM_ARGUMENTS_RE.fullmatch(raw_args) is None:
772 raise ValueError(
773 f'Invalid arguments for SVG transform {name!r}: {raw_args!r}'
774 )
775 tokens = tuple(_TRANSFORM_NUMBER_RE.findall(raw_args))
776 values = tuple(float(token) for token in tokens)
777 if not all(math.isfinite(value) for value in values):
778 raise ValueError(f'Non-finite arguments for SVG transform {name!r}')
779 if len(values) not in _TRANSFORM_ARITIES[name]:
780 expected = '/'.join(str(value) for value in sorted(_TRANSFORM_ARITIES[name]))
781 raise ValueError(
782 f'SVG transform {name!r} has {len(values)} argument(s); '
783 f'expected {expected}'
784 )
785 operations.append((name, values))
786 number_tokens.extend(tokens)
787 cursor = match.end()
788
789 trailing = transform_str[cursor:]
790 if _TRANSFORM_WHITESPACE_RE.fullmatch(trailing) is None:
791 raise ValueError(
792 f'Invalid SVG transform trailing syntax at offset {cursor}: '
793 f'{trailing!r}'
794 )
795
796 return tuple(operations), tuple(number_tokens)
797
798
799 def parse_transform_operations(
800 transform_str: str,
801 ) -> tuple[tuple[str, tuple[float, ...]], ...]:
802 """Parse one complete supported SVG transform list."""
803 operations, _ = _parse_transform_operations(transform_str)
804 return operations
805
806
807 def noncanonical_transform_numbers(transform_str: str) -> tuple[str, ...]:
808 """Return compatible transform numbers generated SVG should normalize."""
809 _, tokens = _parse_transform_operations(transform_str)
810 return tuple(
811 token
812 for token in tokens
813 if _CANONICAL_TRANSFORM_NUMBER_RE.fullmatch(token) is None
814 )
815
816
817 def _transform_operations_matrix(
818 operations: tuple[tuple[str, tuple[float, ...]], ...],
819 ) -> AffineMatrix:
820 matrix = IDENTITY_MATRIX
821 for name, args in operations:
822 if name == 'matrix':
823 local = (args[0], args[1], args[2], args[3], args[4], args[5])
824 elif name == 'translate':
825 local = _translate_matrix(
826 args[0],
827 args[1] if len(args) > 1 else 0.0,
828 )
829 elif name == 'scale':
830 local = _scale_matrix(
831 args[0],
832 args[1] if len(args) > 1 else None,
833 )
834 else:
835 local = _rotate_matrix(
836 args[0],
837 args[1] if len(args) > 2 else None,
838 args[2] if len(args) > 2 else None,
839 )
840 matrix = matrix_multiply(matrix, local)
841 return matrix
842
843
844 def parse_transform_matrix(transform_str: str) -> AffineMatrix:
845 """Parse a complete SVG transform list into one affine matrix.
846
847 Unsupported or malformed operations fail closed. Treating an unknown
848 operation as the identity would silently discard a visible SVG edit.
849 """
850 if not transform_str:
851 return IDENTITY_MATRIX
852 return _transform_operations_matrix(parse_transform_operations(transform_str))
853
854
855 def transform_point(matrix: AffineMatrix, x: float, y: float) -> tuple[float, float]:
856 """Apply an SVG affine matrix to a point."""
857 a, b, c, d, e, f = matrix
858 return a * x + c * y + e, b * x + d * y + f
859
860
861 def validate_dml_shape_matrix(matrix: AffineMatrix) -> None:
862 """Reject affine shear that a DrawingML shape transform cannot express."""
863 if not all(math.isfinite(value) for value in matrix):
864 raise ValueError('SVG transform produces non-finite matrix values')
865 a, b, c, d, _e, _f = matrix
866 x_length = math.hypot(a, b)
867 y_length = math.hypot(c, d)
868 if not math.isfinite(x_length) or not math.isfinite(y_length):
869 raise ValueError('SVG transform produces non-finite axis lengths')
870 if x_length <= 1e-12 or y_length <= 1e-12:
871 raise ValueError(
872 'SVG zero-scale transform cannot be represented by a visible '
873 'DrawingML shape'
874 )
875 normalized_dot = (
876 (a / x_length) * (c / y_length)
877 + (b / x_length) * (d / y_length)
878 )
879 if not math.isfinite(normalized_dot) or abs(normalized_dot) > 1e-9:
880 raise ValueError(
881 'SVG shear/skew cannot be represented by a DrawingML '
882 'shape transform'
883 )
884
885
886 def _svg_element_tag(elem: ET.Element) -> str | None:
887 raw_tag = str(elem.tag)
888 if raw_tag.startswith('{'):
889 namespace, tag = raw_tag[1:].split('}', 1)
890 return tag if namespace == SVG_NS else None
891 return raw_tag
892
893
894 def _transform_element_label(elem: ET.Element) -> str:
895 tag = _svg_element_tag(elem) or str(elem.tag)
896 elem_id = elem.get('id')
897 return f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
898
899
900 def _visual_transform_children(elem: ET.Element) -> list[ET.Element]:
901 return [
902 child
903 for child in elem
904 if _svg_element_tag(child) not in _NON_VISUAL_TRANSFORM_CHILD_TAGS
905 ]
906
907
908 def _iter_visual_transform_tree(elem: ET.Element) -> Iterator[ET.Element]:
909 """Yield one rendered subtree while excluding definition/metadata branches."""
910 yield elem
911 for child in _visual_transform_children(elem):
912 yield from _iter_visual_transform_tree(child)
913
914
915 _TRANSFORM_ARC_STYLE_ATTRS = (
916 'fill',
917 'stroke',
918 'stroke-width',
919 'stroke-dasharray',
920 )
921
922
923 def _transform_arc_styles(
924 elem: ET.Element,
925 inherited: dict[str, str] | None = None,
926 ) -> dict[str, str]:
927 values = dict(inherited or {})
928 inline_style = parse_inline_style(elem.get('style'))
929 for name in _TRANSFORM_ARC_STYLE_ATTRS:
930 direct = elem.get(name)
931 if direct is not None:
932 values[name] = direct
933 if name in inline_style:
934 values[name] = inline_style[name]
935 return values
936
937
938 def _is_project_thick_circle(
939 elem: ET.Element,
940 arc_styles: dict[str, str],
941 ) -> bool:
942 if _svg_element_tag(elem) != 'circle':
943 return False
944 try:
945 radius = parse_project_geometry_length(elem.get('r') or '0', 'r')
946 stroke_width = parse_project_geometry_length(
947 arc_styles.get('stroke-width', '0'),
948 'stroke-width',
949 )
950 except ValueError:
951 # Geometry preflight owns malformed length diagnostics.
952 return False
953 return is_thick_circle_shorthand(
954 arc_styles.get('stroke-dasharray'),
955 arc_styles.get('stroke'),
956 arc_styles.get('fill'),
957 stroke_width,
958 radius,
959 )
960
961
962 def supports_full_project_transform(
963 elem: ET.Element,
964 inherited_arc_styles: dict[str, str] | None = None,
965 ) -> bool:
966 """Return whether one subtree can consume an affine matrix without text loss."""
967 tag = _svg_element_tag(elem)
968 arc_styles = _transform_arc_styles(elem, inherited_arc_styles)
969 if _is_project_thick_circle(elem, arc_styles):
970 # Thick-circle arcs consume scalar context plus one local rotation;
971 # treating an ancestor as a full matrix would silently drop it.
972 return False
973 if tag in _FULL_TRANSFORM_TAGS:
974 return True
975 if tag == 'use':
976 # Local/data-icon use references are validated again after expansion.
977 return True
978 if tag == 'svg':
979 children = _visual_transform_children(elem)
980 return len(children) == 1 and _svg_element_tag(children[0]) == 'image'
981 if tag == 'g':
982 children = _visual_transform_children(elem)
983 return bool(children) and all(
984 supports_full_project_transform(child, arc_styles)
985 for child in children
986 )
987 return False
988
989
990 def iter_project_transforms(
991 root: ET.Element,
992 ) -> Iterator[tuple[ET.Element, str]]:
993 """Yield explicit SVG transform attributes from the project surface."""
994 for elem in root.iter():
995 if _svg_element_tag(elem) is None:
996 continue
997 raw = elem.get('transform')
998 if raw is not None:
999 yield elem, raw
1000
1001
1002 def _has_positive_rounding(elem: ET.Element) -> bool:
1003 for attr in ('rx', 'ry'):
1004 raw = elem.get(attr)
1005 if raw is None:
1006 continue
1007 try:
1008 if parse_project_geometry_length(raw, attr) > 0:
1009 return True
1010 except ValueError:
1011 # Geometry preflight owns the malformed length diagnostic.
1012 continue
1013 return False
1014
1015
1016 def _contains_rounded_rect(elem: ET.Element) -> bool:
1017 return any(
1018 _svg_element_tag(descendant) == 'rect'
1019 and _has_positive_rounding(descendant)
1020 for descendant in _iter_visual_transform_tree(elem)
1021 )
1022
1023
1024 def _contains_native_marker(elem: ET.Element) -> bool:
1025 # Import lazily to avoid the native-object package's dependency on this
1026 # shared DrawingML utility module during initialization.
1027 from ..native_objects.marker_attributes import native_replacement_kind
1028
1029 return any(
1030 native_replacement_kind(descendant) in {'table', 'chart', 'formula'}
1031 for descendant in _iter_visual_transform_tree(elem)
1032 )
1033
1034
1035 def _project_thick_circle_ids(
1036 root: ET.Element,
1037 ) -> set[int]:
1038 thick_circle_ids: set[int] = set()
1039
1040 def visit(
1041 elem: ET.Element,
1042 inherited: dict[str, str] | None = None,
1043 ) -> None:
1044 arc_styles = _transform_arc_styles(elem, inherited)
1045 if _is_project_thick_circle(elem, arc_styles):
1046 thick_circle_ids.add(id(elem))
1047 for child in elem:
1048 visit(child, arc_styles)
1049
1050 visit(root)
1051 return thick_circle_ids
1052
1053
1054 _PROJECT_STROKE_STYLE_ATTRIBUTES = (
1055 'stroke-dasharray',
1056 'stroke-dashoffset',
1057 'stroke-linecap',
1058 'stroke-linejoin',
1059 'vector-effect',
1060 )
1061
1062
1063 def iter_project_stroke_styles(
1064 root: ET.Element,
1065 ) -> Iterator[tuple[ET.Element, str, str, str]]:
1066 """Yield project line-style values with their declaration source."""
1067 for elem in root.iter():
1068 for attribute in _PROJECT_STROKE_STYLE_ATTRIBUTES:
1069 raw = elem.get(attribute)
1070 if raw is not None:
1071 yield elem, attribute, raw, 'attribute'
1072 inline_style = parse_inline_style(elem.get('style'))
1073 for attribute in _PROJECT_STROKE_STYLE_ATTRIBUTES:
1074 raw = inline_style.get(attribute)
1075 if raw is not None:
1076 yield elem, attribute, raw, 'inline style'
1077
1078
1079 def project_stroke_style_errors(root: ET.Element) -> list[str]:
1080 """Return blocking line-style grammar and mapping errors for preflight."""
1081 thick_circle_ids = _project_thick_circle_ids(root)
1082 errors: set[str] = set()
1083 for elem, attribute, raw, source in iter_project_stroke_styles(root):
1084 label = _transform_element_label(elem)
1085 try:
1086 if attribute == 'stroke-dasharray':
1087 parse_project_stroke_dasharray(
1088 raw,
1089 allow_zero_gap=id(elem) in thick_circle_ids,
1090 )
1091 elif attribute == 'stroke-dashoffset':
1092 if source != 'attribute':
1093 raise ValueError(
1094 'is supported only as a direct attribute on a '
1095 'thick-circle arc'
1096 )
1097 parse_project_geometry_length(raw, attribute)
1098 if id(elem) not in thick_circle_ids:
1099 raise ValueError(
1100 'is supported only on a circle that satisfies the '
1101 'thick-circle arc contract'
1102 )
1103 else:
1104 parse_project_stroke_enum(attribute, raw)
1105 except ValueError as exc:
1106 errors.add(f'{label} {source} {attribute}={raw!r}: {exc}')
1107 return sorted(errors)
1108
1109
1110 def _contains_thick_circle(elem: ET.Element, thick_circle_ids: set[int]) -> bool:
1111 return any(
1112 id(descendant) in thick_circle_ids
1113 for descendant in _iter_visual_transform_tree(elem)
1114 )
1115
1116
1117 def _is_unit_axis_reflection(
1118 operations: tuple[tuple[str, tuple[float, ...]], ...],
1119 ) -> bool:
1120 """Return whether a transform is translation plus an unscaled axis flip."""
1121 has_explicit_flip = any(
1122 (
1123 name == 'scale'
1124 and (
1125 args[0] < 0
1126 or (len(args) > 1 and args[1] < 0)
1127 )
1128 )
1129 or (
1130 name == 'matrix'
1131 and (args[0] < 0 or args[3] < 0)
1132 )
1133 for name, args in operations
1134 )
1135 if not has_explicit_flip:
1136 return False
1137 matrix = _transform_operations_matrix(operations)
1138 a, b, c, d, _e, _f = matrix
1139 return (
1140 abs(b) <= 1e-9
1141 and abs(c) <= 1e-9
1142 and math.isclose(abs(a), 1.0, abs_tol=1e-9)
1143 and math.isclose(abs(d), 1.0, abs_tol=1e-9)
1144 and (a < 0 or d < 0)
1145 )
1146
1147
1148 def _transform_semantic_error(
1149 elem: ET.Element,
1150 operations: tuple[tuple[str, tuple[float, ...]], ...],
1151 *,
1152 is_root: bool,
1153 thick_circle_ids: set[int],
1154 ) -> str | None:
1155 tag = _svg_element_tag(elem)
1156 names = tuple(name for name, _args in operations)
1157 label = _transform_element_label(elem)
1158
1159 if is_root:
1160 return (
1161 'Root <svg> transform is unsupported; apply transforms to child '
1162 'elements or groups'
1163 )
1164
1165 if tag == 'text':
1166 if all(name == 'translate' for name in names):
1167 return None
1168 if len(names) == 1 and names[0] == 'rotate':
1169 return None
1170 return (
1171 f'{label} text transform must be a translate-only list or one '
1172 'rotate operation; text scale, matrix, and mixed operations are '
1173 'not mapped'
1174 )
1175
1176 if tag in _TRANSFORM_CONTAINER_TAGS:
1177 if _contains_native_marker(elem):
1178 if all(name in {'translate', 'scale'} for name in names):
1179 return None
1180 return (
1181 f'{label} native replacement marker transforms support only '
1182 'translate and scale'
1183 )
1184 if _contains_thick_circle(elem, thick_circle_ids):
1185 if all(name == 'translate' for name in names):
1186 return None
1187 return (
1188 f'{label} contains a thick-circle arc shorthand; ancestor '
1189 'transforms must be translate-only'
1190 )
1191 if _is_unit_axis_reflection(operations):
1192 # Imported PowerPoint groups encode flipH/flipV as a translate /
1193 # unit-scale / translate list. The converter distributes that
1194 # signed unit scale to child geometry and text positions without
1195 # scaling font metrics, so this exact no-shear case is lossless.
1196 return None
1197 if supports_full_project_transform(elem):
1198 if 'matrix' in names and _contains_rounded_rect(elem):
1199 return (
1200 f'{label} matrix transform cannot target a rounded '
1201 'rectangle subtree'
1202 )
1203 return None
1204 if all(name == 'translate' for name in names):
1205 return None
1206 if len(names) == 1 and names[0] == 'rotate':
1207 return None
1208 return (
1209 f'{label} contains text or another non-matrix visual; its transform '
1210 'must be a translate-only list or one rotate operation'
1211 )
1212
1213 if tag in _FULL_TRANSFORM_TAGS:
1214 if id(elem) in thick_circle_ids:
1215 if len(names) == 1 and names[0] == 'rotate':
1216 return None
1217 return (
1218 f'{label} thick-circle arc transform must be one rotate '
1219 'operation'
1220 )
1221 if tag == 'rect' and 'matrix' in names and _has_positive_rounding(elem):
1222 return f'{label} rounded rectangles cannot use matrix transforms'
1223 return None
1224
1225 if tag == 'svg' and supports_full_project_transform(elem):
1226 return None
1227
1228 return f'{label} has no registered project transform mapping'
1229
1230
1231 def project_transform_errors(root: ET.Element) -> list[str]:
1232 """Return blocking transform grammar and mapping errors for preflight."""
1233 parent_by_id = {
1234 id(child): parent
1235 for parent in root.iter()
1236 for child in list(parent)
1237 }
1238 thick_circle_ids = _project_thick_circle_ids(root)
1239 parsed: dict[int, tuple[AffineMatrix, bool]] = {}
1240 errors: set[str] = set()
1241
1242 for elem, raw in iter_project_transforms(root):
1243 label = _transform_element_label(elem)
1244 restricted_ancestor = None
1245 current = parent_by_id.get(id(elem))
1246 while current is not None:
1247 current_tag = _svg_element_tag(current)
1248 if current_tag in _TRANSFORM_DEFINITION_BOUNDARIES:
1249 restricted_ancestor = current_tag
1250 break
1251 current = parent_by_id.get(id(current))
1252 if restricted_ancestor is not None:
1253 errors.add(
1254 f'{label} cannot use transform inside <{restricted_ancestor}>'
1255 )
1256 parsed[id(elem)] = (IDENTITY_MATRIX, False)
1257 continue
1258
1259 try:
1260 operations = parse_transform_operations(raw)
1261 if not operations:
1262 raise ValueError('SVG transform must not be empty')
1263 matrix = _transform_operations_matrix(operations)
1264 except ValueError as exc:
1265 errors.add(f'{label} transform={raw!r}: {exc}')
1266 parsed[id(elem)] = (IDENTITY_MATRIX, False)
1267 continue
1268
1269 semantic_error = _transform_semantic_error(
1270 elem,
1271 operations,
1272 is_root=elem is root,
1273 thick_circle_ids=thick_circle_ids,
1274 )
1275 if semantic_error is not None:
1276 errors.add(semantic_error)
1277 parsed[id(elem)] = (matrix, semantic_error is None)
1278
1279 def validate_branch(elem: ET.Element, parent_matrix: AffineMatrix) -> None:
1280 current_matrix = parent_matrix
1281 entry = parsed.get(id(elem))
1282 if entry is not None:
1283 local_matrix, semantic_ok = entry
1284 if not semantic_ok:
1285 return
1286 current_matrix = matrix_multiply(parent_matrix, local_matrix)
1287 try:
1288 validate_dml_shape_matrix(current_matrix)
1289 except ValueError as exc:
1290 errors.add(
1291 f'{_transform_element_label(elem)} has an unsupported '
1292 f'cumulative transform: {exc}'
1293 )
1294 return
1295 for child in elem:
1296 validate_branch(child, current_matrix)
1297
1298 validate_branch(root, IDENTITY_MATRIX)
1299 return sorted(errors)
1300
1301
1302 def rect_to_dml_xfrm(
1303 x: float,
1304 y: float,
1305 w: float,
1306 h: float,
1307 matrix: AffineMatrix,
1308 *,
1309 preserve_degenerate_axes: bool = False,
1310 ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
1311 """Map a transformed SVG rectangle to DrawingML xfrm attributes.
1312
1313 DrawingML can represent rotated/flipped rectangles, but not arbitrary
1314 shear. Template-import picture wrappers only use translate/rotate/scale,
1315 so decomposing the transformed local X/Y axes is sufficient here.
1316 """
1317 p0 = transform_point(matrix, x, y)
1318 p1 = transform_point(matrix, x + w, y)
1319 p2 = transform_point(matrix, x + w, y + h)
1320 p3 = transform_point(matrix, x, y + h)
1321
1322 ux = p1[0] - p0[0]
1323 uy = p1[1] - p0[1]
1324 vx = p3[0] - p0[0]
1325 vy = p3[1] - p0[1]
1326
1327 rect_w = math.hypot(ux, uy)
1328 rect_h = math.hypot(vx, vy)
1329 validate_dml_shape_matrix(matrix)
1330 if not preserve_degenerate_axes:
1331 rect_w = max(rect_w, 0.001)
1332 rect_h = max(rect_h, 0.001)
1333 cross = ux * vy - uy * vx
1334
1335 if rect_w <= 1e-12 and rect_h > 1e-12:
1336 angle_deg = math.degrees(math.atan2(vy, vx)) - 90.0
1337 flip_attr = ''
1338 elif cross < 0:
1339 angle_deg = math.degrees(math.atan2(-uy, -ux))
1340 flip_attr = ' flipH="1"'
1341 else:
1342 angle_deg = math.degrees(math.atan2(uy, ux))
1343 flip_attr = ''
1344
1345 rot = round(angle_deg * ANGLE_UNIT)
1346 rot_attr = f' rot="{rot}"' if rot else ''
1347
1348 center_x = (p0[0] + p2[0]) / 2
1349 center_y = (p0[1] + p2[1]) / 2
1350 off_x = px_to_emu(center_x - rect_w / 2)
1351 off_y = px_to_emu(center_y - rect_h / 2)
1352 ext_cx = px_to_emu(rect_w)
1353 ext_cy = px_to_emu(rect_h)
1354 validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy)
1355
1356 xs = [p0[0], p1[0], p2[0], p3[0]]
1357 ys = [p0[1], p1[1], p2[1], p3[1]]
1358 bounds = (
1359 px_to_emu(min(xs)),
1360 px_to_emu(min(ys)),
1361 px_to_emu(max(xs)),
1362 px_to_emu(max(ys)),
1363 )
1364
1365 return f'{flip_attr}{rot_attr}', off_x, off_y, ext_cx, ext_cy, bounds
1366
1367
1368 def _extract_inheritable_styles(elem: ET.Element) -> dict[str, str]:
1369 """Extract all SVG-inheritable presentation attributes from an element."""
1370 styles: dict[str, str] = {}
1371 for attr in INHERITABLE_ATTRS:
1372 val = elem.get(attr)
1373 if val is not None:
1374 styles[attr] = val
1375 styles.update({
1376 attr: val
1377 for attr, val in parse_inline_style(elem.get('style')).items()
1378 if attr in INHERITABLE_ATTRS
1379 })
1380 return styles
1381
1382
1383 def _get_attr(elem: ET.Element, attr: str, ctx: ConvertContext) -> str | None:
1384 """Get effective attribute: element's own value first, then inherited."""
1385 style_val = parse_inline_style(elem.get('style')).get(attr)
1386 if style_val is not None:
1387 return style_val
1388 val = elem.get(attr)
1389 if val is not None:
1390 return val
1391 return ctx.inherited_styles.get(attr)
1392
1393
1394 def ctx_x(val: float, ctx: ConvertContext) -> float:
1395 """Apply context scale + translate to an X coordinate."""
1396 return val * ctx.scale_x + ctx.translate_x
1397
1398
1399 def ctx_y(val: float, ctx: ConvertContext) -> float:
1400 """Apply context scale + translate to a Y coordinate."""
1401 return val * ctx.scale_y + ctx.translate_y
1402
1403
1404 def ctx_w(val: float, ctx: ConvertContext) -> float:
1405 """Apply context scale to a width value."""
1406 return val * ctx.scale_x
1407
1408
1409 def ctx_h(val: float, ctx: ConvertContext) -> float:
1410 """Apply context scale to a height value."""
1411 return val * ctx.scale_y
1412
1413
1414 # ---------------------------------------------------------------------------
1415 # Color / style parsing
1416 # ---------------------------------------------------------------------------
1417
1418 _CSS_NAMED_COLORS = {
1419 'black': '000000',
1420 'silver': 'C0C0C0',
1421 'gray': '808080',
1422 'grey': '808080',
1423 'white': 'FFFFFF',
1424 'maroon': '800000',
1425 'red': 'FF0000',
1426 'purple': '800080',
1427 'fuchsia': 'FF00FF',
1428 'magenta': 'FF00FF',
1429 'green': '008000',
1430 'lime': '00FF00',
1431 'olive': '808000',
1432 'yellow': 'FFFF00',
1433 'navy': '000080',
1434 'blue': '0000FF',
1435 'teal': '008080',
1436 'aqua': '00FFFF',
1437 'cyan': '00FFFF',
1438 'orange': 'FFA500',
1439 'brown': 'A52A2A',
1440 'pink': 'FFC0CB',
1441 'gold': 'FFD700',
1442 'transparent': None,
1443 'lightgray': 'D3D3D3',
1444 'lightgrey': 'D3D3D3',
1445 'darkgray': 'A9A9A9',
1446 'darkgrey': 'A9A9A9',
1447 }
1448
1449
1450 def parse_inline_style(style_str: str | None) -> dict[str, str]:
1451 """Parse an SVG inline style declaration into ``property: value`` pairs."""
1452 styles: dict[str, str] = {}
1453 if not style_str:
1454 return styles
1455 for part in style_str.split(';'):
1456 if ':' not in part:
1457 continue
1458 name, value = part.split(':', 1)
1459 name = name.strip().lower()
1460 value = value.strip()
1461 if name and value:
1462 styles[name] = value
1463 return styles
1464
1465
1466 def iter_project_geometry_lengths(
1467 root: ET.Element,
1468 ) -> Iterator[tuple[ET.Element, str, str, str]]:
1469 """Yield project geometry values as element, attribute, raw value, source."""
1470 for elem in root.iter():
1471 tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
1472 for attribute in sorted(
1473 PROJECT_GEOMETRY_LENGTH_ATTRIBUTES.get(tag, frozenset())
1474 ):
1475 raw = elem.get(attribute)
1476 if raw is not None:
1477 yield elem, attribute, raw, 'attribute'
1478
1479 direct_stroke_width = elem.get('stroke-width')
1480 if direct_stroke_width is not None:
1481 yield elem, 'stroke-width', direct_stroke_width, 'attribute'
1482
1483 style_stroke_width = parse_inline_style(elem.get('style')).get('stroke-width')
1484 if style_stroke_width is not None:
1485 yield elem, 'stroke-width', style_stroke_width, 'inline style'
1486
1487
1488 def project_geometry_length_errors(root: ET.Element) -> list[str]:
1489 """Return blocking project geometry errors for converter preflight."""
1490 errors: list[str] = []
1491 for elem, attribute, raw, source in iter_project_geometry_lengths(root):
1492 tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
1493 elem_id = elem.get('id')
1494 label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
1495 try:
1496 parse_project_geometry_length(raw, attribute)
1497 except ValueError as exc:
1498 errors.append(
1499 f'{label} {source} {attribute}={raw!r}: {exc}'
1500 )
1501 return errors
1502
1503
1504 def iter_project_image_aspect_ratios(
1505 root: ET.Element,
1506 ) -> Iterator[tuple[ET.Element, str]]:
1507 """Yield explicit ``preserveAspectRatio`` values from image elements."""
1508 for elem in root.iter():
1509 if _svg_element_tag(elem) != 'image':
1510 continue
1511 raw = elem.get('preserveAspectRatio')
1512 if raw is not None:
1513 yield elem, raw
1514
1515
1516 def project_image_aspect_ratio_errors(root: ET.Element) -> list[str]:
1517 """Return blocking project image aspect-ratio errors for preflight."""
1518 errors: list[str] = []
1519 for elem, raw in iter_project_image_aspect_ratios(root):
1520 elem_id = elem.get('id')
1521 label = f'<image id={elem_id!r}>' if elem_id else '<image>'
1522 try:
1523 parse_project_image_aspect_ratio(raw)
1524 except ValueError as exc:
1525 errors.append(f'{label} preserveAspectRatio={raw!r}: {exc}')
1526 return errors
1527
1528
1529 def iter_project_opacities(
1530 root: ET.Element,
1531 ) -> Iterator[tuple[ET.Element, str, str, str]]:
1532 """Yield project opacity values as element, property, raw value, source."""
1533 for elem in root.iter():
1534 for property_name in PROJECT_OPACITY_PROPERTIES:
1535 raw = elem.get(property_name)
1536 if raw is not None:
1537 yield elem, property_name, raw, 'attribute'
1538
1539 for fragment in (elem.get('style') or '').split(';'):
1540 fragment = fragment.strip()
1541 if not fragment:
1542 continue
1543 if ':' in fragment:
1544 name, raw = fragment.split(':', 1)
1545 name = name.strip().lower()
1546 raw = raw.strip()
1547 else:
1548 name = fragment.lower()
1549 raw = ''
1550 if name in PROJECT_OPACITY_PROPERTIES:
1551 yield elem, name, raw, 'inline style'
1552
1553
1554 def project_opacity_errors(root: ET.Element) -> list[str]:
1555 """Return blocking project opacity errors for converter preflight."""
1556 errors: list[str] = []
1557 for elem, property_name, raw, source in iter_project_opacities(root):
1558 tag = _svg_element_tag(elem) or str(elem.tag)
1559 elem_id = elem.get('id')
1560 label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
1561 try:
1562 parse_project_opacity(
1563 raw,
1564 allow_percentage=(
1565 property_name in PROJECT_PERCENTAGE_OPACITY_PROPERTIES
1566 ),
1567 )
1568 except ValueError as exc:
1569 errors.append(
1570 f'{label} {source} {property_name}={raw!r}: {exc}'
1571 )
1572 return errors
1573
1574
1575 def _finite_float(raw: str) -> float:
1576 """Parse a finite floating-point number."""
1577 value = float(raw)
1578 if not math.isfinite(value):
1579 raise ValueError(f'Non-finite numeric value: {raw}')
1580 return value
1581
1582
1583 def _parse_color_channel(raw: str) -> int:
1584 raw = raw.strip()
1585 if raw.endswith('%'):
1586 value = _finite_float(raw[:-1]) * 255.0 / 100.0
1587 else:
1588 value = _finite_float(raw)
1589 return max(0, min(255, int(round(value))))
1590
1591
1592 def _parse_alpha_channel(raw: str) -> float:
1593 """Parse a CSS alpha channel as a clamped ``0..1`` ratio."""
1594 raw = raw.strip()
1595 value = (
1596 _finite_float(raw[:-1]) / 100.0
1597 if raw.endswith('%')
1598 else _finite_float(raw)
1599 )
1600 return max(0.0, min(1.0, value))
1601
1602
1603 def parse_opacity(
1604 raw: str | None,
1605 default: float = 1.0,
1606 *,
1607 allow_percentage: bool = False,
1608 ) -> float:
1609 """Parse one project opacity or return the code-owned missing default."""
1610 if raw is None:
1611 return max(0.0, min(1.0, default))
1612 return parse_project_opacity(raw, allow_percentage=allow_percentage)
1613
1614
1615 def quantize_ooxml_unit_ratio(value: float) -> int:
1616 """Quantize one normalized ratio to DrawingML 1/100000 units."""
1617 if not math.isfinite(value):
1618 raise ValueError(f'OOXML unit ratio must be finite; got {value!r}')
1619 normalized = max(0.0, min(1.0, value))
1620 scaled = Decimal(str(normalized)) * Decimal(100000)
1621 return int(scaled.to_integral_value(rounding=ROUND_HALF_UP))
1622
1623
1624 def quantize_ooxml_alpha(opacity: float) -> int:
1625 """Quantize one normalized alpha to DrawingML 1/100000 units."""
1626 if not math.isfinite(opacity):
1627 raise ValueError(f'Opacity must be finite; got {opacity!r}')
1628 return quantize_ooxml_unit_ratio(opacity)
1629
1630
1631 def _functional_color_parts(body: str) -> tuple[list[str], str | None]:
1632 """Split legacy comma or modern space/slash functional color syntax."""
1633 before, separator, after = body.partition('/')
1634 parts = [part for part in re.split(r'[\s,]+', before.strip()) if part]
1635 alpha = after.strip() if separator else None
1636 if alpha is None and len(parts) > 3:
1637 alpha = parts.pop()
1638 return parts, alpha
1639
1640
1641 def _parse_hue_degrees(raw: str) -> float:
1642 """Normalize a CSS hue angle to degrees."""
1643 value = raw.strip().lower()
1644 for suffix, multiplier in (
1645 ('turn', 360.0),
1646 ('grad', 0.9),
1647 ('rad', 180.0 / math.pi),
1648 ('deg', 1.0),
1649 ):
1650 if value.endswith(suffix):
1651 return _finite_float(value[:-len(suffix)]) * multiplier
1652 return _finite_float(value)
1653
1654
1655 def _parse_percentage(raw: str) -> float:
1656 """Parse a CSS percentage channel as a clamped ``0..1`` ratio."""
1657 value = raw.strip()
1658 ratio = (
1659 _finite_float(value[:-1]) / 100.0
1660 if value.endswith('%')
1661 else _finite_float(value) / 100.0
1662 )
1663 return max(0.0, min(1.0, ratio))
1664
1665
1666 def parse_svg_color(color_str: str) -> tuple[str | None, float]:
1667 """Parse an SVG/CSS color into ``(RRGGBB, alpha)``."""
1668 if not color_str:
1669 return None, 1.0
1670 color_str = color_str.strip()
1671 named = _CSS_NAMED_COLORS.get(color_str.lower())
1672 if named is not None or color_str.lower() in _CSS_NAMED_COLORS:
1673 if color_str.lower() == 'transparent':
1674 return '000000', 0.0
1675 return named, 1.0
1676
1677 rgb_match = re.match(r'rgba?\((.+)\)$', color_str, flags=re.IGNORECASE)
1678 if rgb_match:
1679 channels, alpha_raw = _functional_color_parts(rgb_match.group(1))
1680 if len(channels) == 3:
1681 try:
1682 r, g, b = (_parse_color_channel(ch) for ch in channels)
1683 alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
1684 return f'{r:02X}{g:02X}{b:02X}', alpha
1685 except ValueError:
1686 return None, 1.0
1687
1688 hsl_match = re.match(r'hsla?\((.+)\)$', color_str, flags=re.IGNORECASE)
1689 if hsl_match:
1690 channels, alpha_raw = _functional_color_parts(hsl_match.group(1))
1691 if len(channels) == 3:
1692 try:
1693 hue = (_parse_hue_degrees(channels[0]) % 360.0) / 360.0
1694 saturation = _parse_percentage(channels[1])
1695 lightness = _parse_percentage(channels[2])
1696 red, green, blue = colorsys.hls_to_rgb(hue, lightness, saturation)
1697 alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
1698 return (
1699 f'{round(red * 255):02X}{round(green * 255):02X}{round(blue * 255):02X}',
1700 alpha,
1701 )
1702 except ValueError:
1703 return None, 1.0
1704
1705 if color_str.startswith('#'):
1706 color_str = color_str[1:]
1707 if len(color_str) == 3:
1708 color_str = ''.join(c * 2 for c in color_str)
1709 elif len(color_str) == 4:
1710 color_str = ''.join(c * 2 for c in color_str)
1711 if len(color_str) == 8 and all(c in '0123456789abcdefABCDEF' for c in color_str):
1712 return color_str[:6].upper(), int(color_str[6:], 16) / 255.0
1713 if len(color_str) == 6 and all(c in '0123456789abcdefABCDEF' for c in color_str):
1714 return color_str.upper(), 1.0
1715 return None, 1.0
1716
1717
1718 def parse_project_paint(
1719 raw: str,
1720 property_name: str,
1721 ) -> tuple[str, str | None, float]:
1722 """Parse one paint value from the closed project grammar.
1723
1724 Returns ``(kind, value, alpha)`` where ``kind`` is ``color``, ``none``,
1725 or ``reference``. Color values are normalized to ``RRGGBB``; reference
1726 values contain the local definition id.
1727 """
1728 if property_name not in PROJECT_PAINT_PROPERTIES:
1729 raise ValueError(f'unknown project paint property {property_name!r}')
1730
1731 value = raw.strip()
1732 if property_name in PROJECT_REFERENCE_PAINT_PROPERTIES:
1733 if value.lower() == 'none':
1734 return 'none', None, 1.0
1735 reference = re.fullmatch(r'url\(#([^)]+)\)', value)
1736 if reference is not None:
1737 return 'reference', reference.group(1), 1.0
1738
1739 color, alpha = parse_svg_color(value)
1740 if color is not None:
1741 return 'color', color, alpha
1742
1743 accepted = (
1744 'a supported color, none, or an exact local url(#id) reference'
1745 if property_name in PROJECT_REFERENCE_PAINT_PROPERTIES
1746 else 'a supported color'
1747 )
1748 raise ValueError(f'must be {accepted}')
1749
1750
1751 def is_project_paint_default_form(raw: str, property_name: str) -> bool:
1752 """Return whether paint uses the generated project spelling."""
1753 value = raw.strip()
1754 if property_name in PROJECT_REFERENCE_PAINT_PROPERTIES:
1755 if value == 'none':
1756 return True
1757 if re.fullmatch(r'url\(#[^)]+\)', value) is not None:
1758 return True
1759 return re.fullmatch(r'#[0-9A-F]{6}', value) is not None
1760
1761
1762 def iter_project_paints(
1763 root: ET.Element,
1764 ) -> Iterator[tuple[ET.Element, str, str, str]]:
1765 """Yield project paint values as element, property, raw value, source."""
1766 for elem in root.iter():
1767 for property_name in PROJECT_PAINT_PROPERTIES:
1768 raw = elem.get(property_name)
1769 if raw is not None:
1770 yield elem, property_name, raw, 'attribute'
1771
1772 for fragment in (elem.get('style') or '').split(';'):
1773 fragment = fragment.strip()
1774 if not fragment:
1775 continue
1776 if ':' in fragment:
1777 name, raw = fragment.split(':', 1)
1778 name = name.strip().lower()
1779 raw = raw.strip()
1780 else:
1781 name = fragment.lower()
1782 raw = ''
1783 if name in PROJECT_PAINT_PROPERTIES:
1784 yield elem, name, raw, 'inline style'
1785
1786
1787 def project_paint_errors(root: ET.Element) -> list[str]:
1788 """Return blocking project paint errors for converter preflight."""
1789 errors: list[str] = []
1790 for elem, property_name, raw, source in iter_project_paints(root):
1791 tag = _svg_element_tag(elem) or str(elem.tag)
1792 elem_id = elem.get('id')
1793 label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>'
1794 try:
1795 parse_project_paint(raw, property_name)
1796 except ValueError as exc:
1797 errors.append(
1798 f'{label} {source} {property_name}={raw!r}: {exc}'
1799 )
1800 return errors
1801
1802
1803 def project_definition_index(
1804 root: ET.Element,
1805 ) -> tuple[dict[str, ET.Element], set[str]]:
1806 """Return direct ``<defs>`` children by id plus duplicate ids."""
1807 definitions: dict[str, ET.Element] = {}
1808 duplicates: set[str] = set()
1809 for defs_elem in root.iter():
1810 if _svg_element_tag(defs_elem) != 'defs':
1811 continue
1812 for child in defs_elem:
1813 definition_id = (child.get('id') or '').strip()
1814 if not definition_id:
1815 continue
1816 if definition_id in definitions:
1817 duplicates.add(definition_id)
1818 definitions[definition_id] = child
1819 return definitions, duplicates
1820
1821
1822 def project_definition_errors(root: ET.Element) -> list[str]:
1823 """Return errors for definitions outside the closed local-ref contract."""
1824 parent_by_id = {
1825 id(child): parent
1826 for parent in root.iter()
1827 for child in list(parent)
1828 }
1829 definitions, duplicate_definition_ids = project_definition_index(root)
1830 errors = {
1831 f'Duplicate direct <defs> id {definition_id!r} makes local references ambiguous'
1832 for definition_id in duplicate_definition_ids
1833 }
1834 all_id_counts = Counter(
1835 elem.get('id')
1836 for elem in root.iter()
1837 if (elem.get('id') or '').strip()
1838 )
1839 for definition_id in definitions:
1840 if all_id_counts[definition_id] > 1:
1841 errors.add(
1842 f'Definition id {definition_id!r} is duplicated in the SVG; '
1843 'local references require one unique target'
1844 )
1845
1846 for elem in root.iter():
1847 tag = _svg_element_tag(elem)
1848 if tag not in PROJECT_DEFINITION_TAGS:
1849 continue
1850 label = _transform_element_label(elem)
1851 parent = parent_by_id.get(id(elem))
1852 if parent is None or _svg_element_tag(parent) != 'defs':
1853 errors.add(f'{label} must be a direct child of <defs>')
1854 if not (elem.get('id') or '').strip():
1855 errors.add(f'{label} requires a non-empty unique id')
1856 return sorted(errors)
1857
1858
1859 def _project_marker_polygon_points(
1860 raw: str,
1861 ) -> list[tuple[float, float]] | None:
1862 """Parse finite marker polygon points from the closed project grammar."""
1863 tokens = [token for token in re.split(r'[\s,]+', raw.strip()) if token]
1864 if not tokens or len(tokens) % 2:
1865 return None
1866 try:
1867 values = [float(token) for token in tokens]
1868 except ValueError:
1869 return None
1870 if not all(math.isfinite(value) for value in values):
1871 return None
1872 return list(zip(values[::2], values[1::2]))
1873
1874
1875 def _project_marker_path_points(raw: str) -> list[tuple[float, float]]:
1876 """Return the explicit M/L points from an already-validated marker path."""
1877 points = [
1878 (float(x), float(y))
1879 for x, y in _PROJECT_MARKER_COMMAND_POINT_RE.findall(raw)
1880 ]
1881 return [
1882 point
1883 for point in points
1884 if all(math.isfinite(coordinate) for coordinate in point)
1885 ]
1886
1887
1888 def _project_marker_cross(
1889 first: tuple[float, float],
1890 second: tuple[float, float],
1891 third: tuple[float, float],
1892 ) -> float:
1893 """Return the signed turn for three marker vertices."""
1894 return (
1895 (second[0] - first[0]) * (third[1] - second[1])
1896 - (second[1] - first[1]) * (third[0] - second[0])
1897 )
1898
1899
1900 def _project_marker_segments_cross(
1901 first_start: tuple[float, float],
1902 first_end: tuple[float, float],
1903 second_start: tuple[float, float],
1904 second_end: tuple[float, float],
1905 ) -> bool:
1906 """Return whether two non-adjacent marker edges strictly intersect."""
1907 first_a = _project_marker_cross(first_start, first_end, second_start)
1908 first_b = _project_marker_cross(first_start, first_end, second_end)
1909 second_a = _project_marker_cross(second_start, second_end, first_start)
1910 second_b = _project_marker_cross(second_start, second_end, first_end)
1911 return first_a * first_b < 0 and second_a * second_b < 0
1912
1913
1914 def _project_marker_quadrilateral_type(
1915 points: list[tuple[float, float]],
1916 ) -> str | None:
1917 """Classify one simple four-point marker as diamond or stealth."""
1918 if len(points) != 4:
1919 return None
1920 if (
1921 _project_marker_segments_cross(points[0], points[1], points[2], points[3])
1922 or _project_marker_segments_cross(
1923 points[1], points[2], points[3], points[0]
1924 )
1925 ):
1926 return None
1927 turns = [
1928 _project_marker_cross(
1929 points[index],
1930 points[(index + 1) % 4],
1931 points[(index + 2) % 4],
1932 )
1933 for index in range(4)
1934 ]
1935 if any(abs(turn) <= 1e-12 for turn in turns):
1936 return None
1937 signs = {turn > 0 for turn in turns}
1938 return 'diamond' if len(signs) == 1 else 'stealth'
1939
1940
1941 def classify_project_marker_shape(marker_elem: ET.Element) -> str | None:
1942 """Classify one marker into a DrawingML line-end shape, if representable."""
1943 visual_children = [
1944 child
1945 for child in list(marker_elem)
1946 if _svg_element_tag(child)
1947 not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
1948 ]
1949 if len(visual_children) != 1:
1950 return None
1951 shape = visual_children[0]
1952 tag = (_svg_element_tag(shape) or '').lower()
1953 if tag in {'circle', 'ellipse'}:
1954 return 'oval'
1955 if tag == 'path':
1956 path_data = shape.get('d', '')
1957 if _PROJECT_MARKER_TRIANGLE_PATH_RE.fullmatch(path_data):
1958 return 'triangle'
1959 if _PROJECT_MARKER_ARROW_PATH_RE.fullmatch(path_data):
1960 return 'arrow'
1961 if _PROJECT_MARKER_DIAMOND_PATH_RE.fullmatch(path_data):
1962 points = _project_marker_path_points(path_data)
1963 return _project_marker_quadrilateral_type(points)
1964 return None
1965 if tag == 'polygon':
1966 points = _project_marker_polygon_points(shape.get('points', ''))
1967 if points is None:
1968 return None
1969 if len(points) == 3:
1970 return 'triangle'
1971 return _project_marker_quadrilateral_type(points)
1972 return None
1973
1974
1975 def _project_effective_presentation_value(
1976 elem: ET.Element,
1977 name: str,
1978 parent_by_id: dict[int, ET.Element],
1979 ) -> str | None:
1980 """Resolve one inherited presentation value for project validation."""
1981 current: ET.Element | None = elem
1982 while current is not None:
1983 style_values = parse_inline_style(current.get('style'))
1984 if name in style_values:
1985 return style_values[name]
1986 direct = current.get(name)
1987 if direct is not None:
1988 return direct
1989 current = parent_by_id.get(id(current))
1990 return None
1991
1992
1993 def project_marker_errors(root: ET.Element) -> list[str]:
1994 """Validate SVG line-end markers against the native arrow contract."""
1995 definitions, _duplicates = project_definition_index(root)
1996 parent_by_id = {
1997 id(child): parent
1998 for parent in root.iter()
1999 for child in list(parent)
2000 }
2001 errors: set[str] = set()
2002 checked_markers: set[str] = set()
2003
2004 for elem in root.iter():
2005 for attribute_name in ('marker-start', 'marker-end'):
2006 raw_reference = elem.get(attribute_name)
2007 if (
2008 raw_reference is None
2009 or raw_reference.strip().lower() == 'none'
2010 ):
2011 continue
2012
2013 label = _transform_element_label(elem)
2014 tag = (_svg_element_tag(elem) or '').lower()
2015 if tag not in {'line', 'path'}:
2016 errors.add(
2017 f'{label} {attribute_name} is allowed only on <line> '
2018 'or <path>'
2019 )
2020
2021 match = re.fullmatch(r'url\(#([^)]+)\)', raw_reference.strip())
2022 if match is None:
2023 errors.add(
2024 f'{label} {attribute_name} must be an exact local '
2025 f'url(#id) reference; got {raw_reference!r}'
2026 )
2027 continue
2028
2029 marker_id = match.group(1)
2030 marker = definitions.get(marker_id)
2031 if marker is None or _svg_element_tag(marker) != 'marker':
2032 errors.add(
2033 f'{label} {attribute_name}=url(#{marker_id}) has no '
2034 f'matching direct <defs><marker id="{marker_id}"> '
2035 'definition'
2036 )
2037 continue
2038
2039 visual_children = [
2040 child
2041 for child in list(marker)
2042 if _svg_element_tag(child)
2043 not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
2044 ]
2045 shape = visual_children[0] if len(visual_children) == 1 else None
2046 marker_shape_type = (
2047 classify_project_marker_shape(marker)
2048 if shape is not None
2049 else None
2050 )
2051 if marker_id not in checked_markers:
2052 checked_markers.add(marker_id)
2053 marker_label = f'<marker id="{marker_id}">'
2054 if marker.get('orient') not in {
2055 'auto',
2056 'auto-start-reverse',
2057 }:
2058 errors.add(
2059 f'{marker_label} requires orient="auto" or '
2060 'orient="auto-start-reverse"'
2061 )
2062 marker_units = marker.get('markerUnits', 'strokeWidth')
2063 if marker_units not in {'strokeWidth', 'userSpaceOnUse'}:
2064 errors.add(
2065 f'{marker_label} has unsupported '
2066 f'markerUnits={marker_units!r}'
2067 )
2068 for size_attribute in ('markerWidth', 'markerHeight'):
2069 raw_size = marker.get(size_attribute)
2070 if raw_size is None:
2071 continue
2072 try:
2073 size = float(raw_size)
2074 except ValueError:
2075 size = math.nan
2076 if not math.isfinite(size) or size <= 0:
2077 errors.add(
2078 f'{marker_label} {size_attribute} must be a '
2079 f'positive finite number; got {raw_size!r}'
2080 )
2081
2082 if shape is None:
2083 errors.add(
2084 f'{marker_label} must contain exactly one direct '
2085 'triangle, stealth, arrow, diamond, or oval shape'
2086 )
2087 else:
2088 shape_tag = (_svg_element_tag(shape) or '').lower()
2089 if shape.get('transform'):
2090 errors.add(
2091 f'{marker_label} child <{shape_tag}> cannot use '
2092 'transform'
2093 )
2094 if marker_shape_type is None and shape_tag == 'path':
2095 errors.add(
2096 f'{marker_label} path must be a closed 3-vertex '
2097 'triangle, a simple closed 4-vertex '
2098 'diamond/stealth, or an open 3-vertex arrow, '
2099 'with one explicit M/L command per vertex'
2100 )
2101 elif (
2102 marker_shape_type is None
2103 and shape_tag == 'polygon'
2104 ):
2105 errors.add(
2106 f'{marker_label} polygon must contain exactly '
2107 '3 finite vertices or 4 finite vertices forming '
2108 'a simple diamond/stealth quadrilateral'
2109 )
2110 elif (
2111 marker_shape_type is None
2112 and shape_tag not in {'circle', 'ellipse'}
2113 ):
2114 errors.add(
2115 f'{marker_label} child <{shape_tag}> has no native '
2116 'line-end mapping'
2117 )
2118
2119 if shape is None:
2120 continue
2121 stroke_value = _project_effective_presentation_value(
2122 elem,
2123 'stroke',
2124 parent_by_id,
2125 )
2126 marker_fill = _project_effective_presentation_value(
2127 shape,
2128 'fill',
2129 parent_by_id,
2130 ) or '#000000'
2131 if marker_shape_type == 'arrow':
2132 if marker_fill.strip().lower() != 'none':
2133 errors.add(
2134 f'{label} {attribute_name}=url(#{marker_id}) open '
2135 'arrow marker requires fill="none"'
2136 )
2137 marker_channel = 'stroke'
2138 marker_paint = _project_effective_presentation_value(
2139 shape,
2140 marker_channel,
2141 parent_by_id,
2142 ) or 'none'
2143 else:
2144 marker_channel = 'fill'
2145 marker_paint = marker_fill
2146 stroke_color, _stroke_alpha = parse_svg_color(stroke_value or '')
2147 marker_color, _marker_alpha = parse_svg_color(marker_paint)
2148 if stroke_color is None or marker_color is None:
2149 errors.add(
2150 f'{label} {attribute_name} marker {marker_channel} and '
2151 'line stroke must both be supported solid colors'
2152 )
2153 elif stroke_color != marker_color:
2154 errors.add(
2155 f'{label} {attribute_name}=url(#{marker_id}) marker '
2156 f'{marker_channel} {marker_paint!r} does not match '
2157 f'effective line stroke {stroke_value!r}'
2158 )
2159
2160 return sorted(errors)
2161
2162
2163 def resolve_project_text_image_fill(pattern: ET.Element) -> tuple[str, ET.Element]:
2164 """Resolve the controlled one-image pattern used for native text picture fills."""
2165 if _svg_element_tag(pattern) != 'pattern':
2166 raise ValueError('definition must be an SVG <pattern>')
2167
2168 mode = pattern.get(PROJECT_TEXT_IMAGE_FILL_ATTR, '')
2169 if mode not in PROJECT_TEXT_IMAGE_FILL_MODES:
2170 supported = ', '.join(sorted(PROJECT_TEXT_IMAGE_FILL_MODES))
2171 raise ValueError(f'{PROJECT_TEXT_IMAGE_FILL_ATTR} must be one of: {supported}')
2172 preset_attributes = [
2173 name
2174 for name in ('data-pptx-pattern', 'data-pptx-fg', 'data-pptx-bg')
2175 if pattern.get(name) is not None
2176 ]
2177 if preset_attributes:
2178 raise ValueError(
2179 'text image fill must not combine preset-pattern attributes: '
2180 f'{", ".join(preset_attributes)}'
2181 )
2182 if pattern.get('patternTransform') is not None:
2183 raise ValueError('pattern must not use patternTransform')
2184
2185 children = list(pattern)
2186 if len(children) != 1 or children[0].tag != f'{{{SVG_NS}}}image':
2187 raise ValueError('pattern must contain exactly one direct SVG <image> child')
2188
2189 image = children[0]
2190 unsupported = [
2191 name
2192 for name in (
2193 'clip-path',
2194 'filter',
2195 'fill-opacity',
2196 'mask',
2197 'opacity',
2198 'style',
2199 'transform',
2200 )
2201 if image.get(name) is not None
2202 ]
2203 if unsupported:
2204 raise ValueError(f"pattern image must not use {', '.join(unsupported)}")
2205 return mode, image
2206
2207
2208 def project_paint_reference_errors(root: ET.Element) -> list[str]:
2209 """Validate local paint-server references and their native contexts."""
2210 definitions, _duplicates = project_definition_index(root)
2211 pattern_descendant_ids = {
2212 id(descendant)
2213 for pattern in root.iter()
2214 if _svg_element_tag(pattern) == 'pattern'
2215 for descendant in pattern.iter()
2216 if descendant is not pattern
2217 }
2218 fill_shape_tags = frozenset({
2219 'rect', 'circle', 'ellipse', 'path', 'polygon', 'polyline',
2220 })
2221 stroke_shape_tags = fill_shape_tags | {'line'}
2222 errors: set[str] = set()
2223
2224 for elem in root.iter():
2225 style_values = parse_inline_style(elem.get('style'))
2226 for property_name in PROJECT_REFERENCE_PAINT_PROPERTIES:
2227 raw = (
2228 style_values[property_name]
2229 if property_name in style_values
2230 else elem.get(property_name)
2231 )
2232 if raw is None:
2233 continue
2234 try:
2235 kind, reference_id, _alpha = parse_project_paint(
2236 raw,
2237 property_name,
2238 )
2239 except ValueError:
2240 continue
2241 if kind != 'reference' or reference_id is None:
2242 continue
2243
2244 elem_tag = _svg_element_tag(elem) or str(elem.tag)
2245 elem_tag_lower = elem_tag.lower()
2246 target = definitions.get(reference_id)
2247 if target is None:
2248 errors.add(
2249 f'<{elem_tag}> {property_name}=url(#{reference_id}) has no '
2250 'matching direct <defs> definition'
2251 )
2252 continue
2253
2254 has_text_descendant = any(
2255 (_svg_element_tag(descendant) or '').lower() in {'text', 'tspan'}
2256 for descendant in elem.iter()
2257 if descendant is not elem
2258 )
2259 if id(elem) in pattern_descendant_ids:
2260 allowed_tags: tuple[str, ...] = ()
2261 elif property_name == 'fill' and elem_tag_lower in fill_shape_tags:
2262 allowed_tags = ('lineargradient', 'radialgradient', 'pattern')
2263 elif property_name == 'stroke' and elem_tag_lower in stroke_shape_tags:
2264 allowed_tags = ('lineargradient', 'radialgradient')
2265 elif property_name == 'fill' and elem_tag_lower in {'text', 'tspan'}:
2266 target_tag = (_svg_element_tag(target) or str(target.tag)).lower()
2267 if target_tag == 'pattern' and target.get(PROJECT_TEXT_IMAGE_FILL_ATTR) is not None:
2268 allowed_tags = ('lineargradient', 'radialgradient', 'pattern')
2269 else:
2270 allowed_tags = ('lineargradient', 'radialgradient')
2271 elif property_name == 'fill' and elem_tag_lower == 'g':
2272 allowed_tags = (
2273 ('lineargradient', 'radialgradient')
2274 if has_text_descendant
2275 else ('lineargradient', 'radialgradient', 'pattern')
2276 )
2277 elif (
2278 property_name == 'stroke'
2279 and elem_tag_lower == 'g'
2280 and not has_text_descendant
2281 ):
2282 allowed_tags = ('lineargradient', 'radialgradient')
2283 else:
2284 allowed_tags = ()
2285
2286 if not allowed_tags:
2287 errors.add(
2288 f'<{elem_tag}> {property_name}=url(#{reference_id}) is not '
2289 'supported by native PPTX conversion in this context'
2290 )
2291 continue
2292
2293 target_tag = (_svg_element_tag(target) or str(target.tag)).lower()
2294 is_text_image_fill = (
2295 target_tag == 'pattern'
2296 and target.get(PROJECT_TEXT_IMAGE_FILL_ATTR) is not None
2297 )
2298 if is_text_image_fill and not (
2299 property_name == 'fill'
2300 and elem_tag_lower in {'text', 'tspan'}
2301 ):
2302 errors.add(
2303 f'<{elem_tag}> {property_name}=url(#{reference_id}) uses a '
2304 'text image fill pattern outside <text>/<tspan>'
2305 )
2306 continue
2307 if target_tag not in allowed_tags:
2308 tag_labels = {
2309 'lineargradient': 'linearGradient',
2310 'radialgradient': 'radialGradient',
2311 'pattern': 'pattern',
2312 }
2313 expected = '/'.join(tag_labels[tag] for tag in allowed_tags)
2314 errors.add(
2315 f'<{elem_tag}> {property_name}=url(#{reference_id}) resolves '
2316 f'to <{_svg_element_tag(target) or target.tag}>; expected '
2317 f'{expected}'
2318 )
2319 continue
2320
2321 if property_name == 'fill' and elem_tag_lower in {'text', 'tspan'} and target_tag == 'pattern':
2322 try:
2323 resolve_project_text_image_fill(target)
2324 except ValueError as exc:
2325 errors.add(
2326 f'<{elem_tag}> fill=url(#{reference_id}) has an invalid '
2327 f'text image fill: {exc}'
2328 )
2329 return sorted(errors)
2330
2331
2332 def project_mask_errors(root: ET.Element) -> list[str]:
2333 """Reject SVG masks that native PPTX conversion cannot preserve."""
2334 errors: set[str] = set()
2335 for elem in root.iter():
2336 label = _transform_element_label(elem)
2337 if _svg_element_tag(elem) == 'mask':
2338 errors.add(
2339 f'{label} is an unsupported SVG mask definition; replace it '
2340 'with editable overlay or Boolean/cutout shapes, an image '
2341 'clip-path, or pre-rendered alpha imagery'
2342 )
2343
2344 sources: list[str] = []
2345 if any(
2346 name.rsplit('}', 1)[-1].lower() == 'mask'
2347 for name in elem.attrib
2348 ):
2349 sources.append('mask attribute')
2350 if 'mask' in parse_inline_style(elem.get('style')):
2351 sources.append('inline style mask property')
2352 if sources:
2353 errors.add(
2354 f'{label} uses unsupported SVG mask presentation via '
2355 f'{", ".join(sources)}; native PPTX export would drop the '
2356 'effect. Use editable overlay or Boolean/cutout shapes, an '
2357 'image clip-path, or pre-rendered alpha imagery'
2358 )
2359 return sorted(errors)
2360
2361
2362 def parse_project_gradient_ratio(raw: str) -> float:
2363 """Parse one normalized gradient coordinate or stop offset."""
2364 number, unit = _parse_svg_length_parts(raw)
2365 if unit == '%':
2366 number /= 100.0
2367 elif unit:
2368 raise ValueError('must be unitless or a percentage')
2369 if not 0.0 <= number <= 1.0:
2370 raise ValueError('must be within 0..1 or 0%..100%')
2371 return number
2372
2373
2374 def parse_project_linear_gradient_coordinate(raw: str) -> float:
2375 """Parse one objectBoundingBox linear-gradient projection coordinate."""
2376 number, unit = _parse_svg_length_parts(raw)
2377 if unit == '%':
2378 number /= 100.0
2379 elif unit:
2380 raise ValueError('must be unitless or a percentage')
2381 if not (
2382 PROJECT_LINEAR_GRADIENT_COORDINATE_MIN
2383 <= number
2384 <= PROJECT_LINEAR_GRADIENT_COORDINATE_MAX
2385 ):
2386 raise ValueError(
2387 'must be within -0.105..1.105 or -10.5%..110.5%'
2388 )
2389 return number
2390
2391
2392 def is_project_radial_focus_point(focus_x: float, focus_y: float) -> bool:
2393 """Return whether a focus lies inside the canonical SVG radial circle."""
2394 if not math.isfinite(focus_x) or not math.isfinite(focus_y):
2395 return False
2396 return (
2397 (focus_x - 0.5) ** 2 + (focus_y - 0.5) ** 2
2398 <= 0.25 + PROJECT_RADIAL_FOCUS_TOLERANCE
2399 )
2400
2401
2402 def project_gradient_errors(root: ET.Element) -> list[str]:
2403 """Validate the normalized native gradient authoring interface."""
2404 errors: set[str] = set()
2405 for gradient in root.iter():
2406 tag = _svg_element_tag(gradient)
2407 if tag not in PROJECT_GRADIENT_TAGS:
2408 continue
2409 gradient_id = gradient.get('id')
2410 label = f'<{tag} id="{gradient_id}">' if gradient_id else f'<{tag}>'
2411 attribute_names = {
2412 name.rsplit('}', 1)[-1]
2413 for name in gradient.attrib
2414 }
2415 if 'href' in attribute_names:
2416 errors.add(
2417 f'{label} cannot inherit from href/xlink:href; '
2418 'define gradient stops directly'
2419 )
2420 if 'gradientTransform' in attribute_names:
2421 errors.add(f'{label} cannot use gradientTransform')
2422 if 'spreadMethod' in attribute_names:
2423 errors.add(f'{label} cannot use spreadMethod')
2424 gradient_units = gradient.get('gradientUnits')
2425 if gradient_units not in {None, 'objectBoundingBox'}:
2426 errors.add(
2427 f'{label} cannot use gradientUnits={gradient_units!r}; '
2428 'use normalized objectBoundingBox coordinates'
2429 )
2430
2431 if tag == 'linearGradient':
2432 coordinate_defaults = {
2433 'x1': '0',
2434 'y1': '0',
2435 'x2': '1',
2436 'y2': '0',
2437 }
2438 coordinates: dict[str, float] = {}
2439 for coordinate_name, default in coordinate_defaults.items():
2440 raw_coordinate = gradient.get(coordinate_name, default)
2441 try:
2442 coordinates[coordinate_name] = (
2443 parse_project_linear_gradient_coordinate(raw_coordinate)
2444 )
2445 except ValueError:
2446 errors.add(
2447 f'{label} {coordinate_name} must be a finite '
2448 'objectBoundingBox projection coordinate within '
2449 '-0.105..1.105 or -10.5%..110.5%; '
2450 f'got {raw_coordinate!r}'
2451 )
2452 if len(coordinates) == 4 and (
2453 math.isclose(
2454 coordinates['x1'],
2455 coordinates['x2'],
2456 rel_tol=0.0,
2457 abs_tol=1e-12,
2458 )
2459 and math.isclose(
2460 coordinates['y1'],
2461 coordinates['y2'],
2462 rel_tol=0.0,
2463 abs_tol=1e-12,
2464 )
2465 ):
2466 errors.add(
2467 f'{label} linear gradient axis must not collapse to one '
2468 'point; use different x1/y1 and x2/y2 coordinates'
2469 )
2470 else:
2471 radial_coordinates: dict[str, float] = {}
2472 for coordinate_name in ('cx', 'cy', 'r', 'fx', 'fy'):
2473 raw_coordinate = gradient.get(coordinate_name)
2474 if raw_coordinate is None:
2475 continue
2476 try:
2477 coordinate = parse_project_gradient_ratio(raw_coordinate)
2478 except ValueError:
2479 errors.add(
2480 f'{label} {coordinate_name} must be a normalized finite '
2481 'value from 0 to 1 or 0% to 100%; '
2482 f'got {raw_coordinate!r}'
2483 )
2484 continue
2485 radial_coordinates[coordinate_name] = coordinate
2486 if coordinate_name == 'r' and coordinate <= 0:
2487 errors.add(f'{label} r must be greater than 0')
2488 focus_x_name = (
2489 'fx' if gradient.get('fx') is not None else 'cx'
2490 )
2491 focus_y_name = (
2492 'fy' if gradient.get('fy') is not None else 'cy'
2493 )
2494 focus_is_valid = (
2495 (
2496 gradient.get(focus_x_name) is None
2497 or focus_x_name in radial_coordinates
2498 )
2499 and (
2500 gradient.get(focus_y_name) is None
2501 or focus_y_name in radial_coordinates
2502 )
2503 )
2504 if focus_is_valid:
2505 focus_x = radial_coordinates.get(focus_x_name, 0.5)
2506 focus_y = radial_coordinates.get(focus_y_name, 0.5)
2507 if not is_project_radial_focus_point(focus_x, focus_y):
2508 errors.add(
2509 f'{label} effective focus (fx/fy, otherwise cx/cy) '
2510 'must lie within the canonical circle centered at '
2511 f'0.5,0.5 with radius 0.5; got ({focus_x}, {focus_y})'
2512 )
2513
2514 stops: list[ET.Element] = []
2515 for child in list(gradient):
2516 child_tag = _svg_element_tag(child) or str(child.tag)
2517 if child_tag in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS:
2518 continue
2519 if child_tag != 'stop':
2520 errors.add(
2521 f'{label} has unsupported direct child <{child_tag}>; '
2522 'gradient definitions may contain only direct <stop> children'
2523 )
2524 continue
2525 stops.append(child)
2526 if len(stops) < 2:
2527 errors.add(
2528 f'{label} requires at least two direct <stop> children for '
2529 'native PPTX gradient interpolation'
2530 )
2531 previous_offset: float | None = None
2532 for index, stop in enumerate(stops, start=1):
2533 stop_label = f'{label} stop #{index}'
2534 raw_offset = stop.get('offset')
2535 try:
2536 if raw_offset is None:
2537 raise ValueError
2538 offset = parse_project_gradient_ratio(raw_offset)
2539 except ValueError:
2540 errors.add(
2541 f'{stop_label} offset must be explicit and within 0..1 '
2542 f'or 0%..100%; got {raw_offset!r}'
2543 )
2544 else:
2545 if (
2546 previous_offset is not None
2547 and offset < previous_offset
2548 ):
2549 errors.add(
2550 f'{label} stop offsets must be non-decreasing; '
2551 f'stop #{index} offset {raw_offset!r} precedes a '
2552 f'larger offset at stop #{index - 1}'
2553 )
2554 previous_offset = offset
2555 style_values = parse_inline_style(stop.get('style'))
2556 if not (style_values.get('stop-color') or stop.get('stop-color')):
2557 errors.add(f'{stop_label} requires an explicit stop-color')
2558 return sorted(errors)
2559
2560
2561 def parse_project_filter_params(
2562 filter_elem: ET.Element,
2563 ) -> dict[str, float | str | bool]:
2564 """Extract the shared native shadow/glow parameters from one filter."""
2565 primitive_units = filter_elem.get('primitiveUnits')
2566 if primitive_units not in (None, 'userSpaceOnUse'):
2567 raise ValueError(
2568 'filter primitiveUnits must be userSpaceOnUse when explicit; '
2569 f'got {primitive_units!r}'
2570 )
2571 std_dev: float | None = None
2572 dx = 0.0
2573 dy = 0.0
2574 paint_opacity: float | None = None
2575 transfer_opacity: float | None = None
2576 color_alpha = 1.0
2577 color = '000000'
2578 has_offset = False
2579
2580 def required_number(primitive: ET.Element, attribute_name: str) -> float:
2581 primitive_tag = _svg_element_tag(primitive) or str(primitive.tag)
2582 raw_value = primitive.get(attribute_name)
2583 if raw_value is None:
2584 raise ValueError(
2585 f'<{primitive_tag}> requires explicit {attribute_name}'
2586 )
2587 try:
2588 value = float(raw_value)
2589 except (TypeError, ValueError) as exc:
2590 raise ValueError(
2591 f'<{primitive_tag}> {attribute_name} must be a finite number; '
2592 f'got {raw_value!r}'
2593 ) from exc
2594 if not math.isfinite(value):
2595 raise ValueError(
2596 f'<{primitive_tag}> {attribute_name} must be a finite number; '
2597 f'got {raw_value!r}'
2598 )
2599 return value
2600
2601 for child in filter_elem.iter():
2602 tag = _svg_element_tag(child)
2603 style_values = parse_inline_style(child.get('style'))
2604
2605 def effect_attr(name: str, default: str | None = None) -> str | None:
2606 return style_values.get(name) or child.get(name, default)
2607
2608 def required_effect_attr(name: str) -> str:
2609 raw_value = effect_attr(name)
2610 if raw_value is None:
2611 raise ValueError(f'<{tag}> requires explicit {name}')
2612 return raw_value
2613
2614 if tag == 'feDropShadow':
2615 std_dev = required_number(child, 'stdDeviation')
2616 dx = required_number(child, 'dx')
2617 dy = required_number(child, 'dy')
2618 if abs(dx) > 0.01 or abs(dy) > 0.01:
2619 has_offset = True
2620 paint_opacity = parse_opacity(
2621 required_effect_attr('flood-opacity'),
2622 allow_percentage=True,
2623 )
2624 parsed_color, parsed_alpha = parse_svg_color(
2625 effect_attr('flood-color', '#000000')
2626 )
2627 if parsed_color:
2628 color = parsed_color
2629 color_alpha = parsed_alpha
2630 elif tag == 'feGaussianBlur':
2631 if child.get('edgeMode') is not None:
2632 raise ValueError(
2633 '<feGaussianBlur> edgeMode is unsupported by the native '
2634 'effect mapping'
2635 )
2636 std_dev = required_number(child, 'stdDeviation')
2637 elif tag == 'feOffset':
2638 dx = _f(child.get('dx'), 0.0)
2639 dy = _f(child.get('dy'), 0.0)
2640 if abs(dx) > 0.01 or abs(dy) > 0.01:
2641 has_offset = True
2642 elif tag == 'feFlood':
2643 paint_opacity = parse_opacity(
2644 required_effect_attr('flood-opacity'),
2645 allow_percentage=True,
2646 )
2647 parsed_color, parsed_alpha = parse_svg_color(
2648 effect_attr('flood-color', '#000000')
2649 )
2650 if parsed_color:
2651 color = parsed_color
2652 color_alpha = parsed_alpha
2653 elif tag == 'feFuncA' and child.get('type') == 'linear':
2654 if child.get('intercept') is not None:
2655 raise ValueError(
2656 '<feFuncA> intercept is unsupported; project alpha '
2657 'transfer maps slope multiplication only'
2658 )
2659 slope = required_number(child, 'slope')
2660 transfer_opacity = (
2661 slope
2662 if transfer_opacity is None
2663 else transfer_opacity * slope
2664 )
2665
2666 if paint_opacity is None:
2667 opacity = transfer_opacity if transfer_opacity is not None else 0.3
2668 elif transfer_opacity is None:
2669 opacity = paint_opacity
2670 else:
2671 opacity = paint_opacity * transfer_opacity
2672 opacity = max(0.0, min(1.0, opacity * color_alpha))
2673
2674 if std_dev is None:
2675 raise ValueError('filter requires feDropShadow or feGaussianBlur')
2676
2677 return {
2678 'std_dev': std_dev,
2679 'dx': dx,
2680 'dy': dy,
2681 'opacity': opacity,
2682 'color': color,
2683 'has_offset': has_offset,
2684 }
2685
2686
2687 def project_filter_drawingml_coordinates(
2688 params: dict[str, float | str | bool],
2689 effect_kind: str | None = None,
2690 ) -> dict[str, int]:
2691 """Map filter geometry into validated DrawingML effect coordinates."""
2692 kind = effect_kind or ('shadow' if params['has_offset'] else 'glow')
2693 std_dev = float(params['std_dev'])
2694 dx = float(params['dx'])
2695 dy = float(params['dy'])
2696 if kind == 'shadow':
2697 coordinates_px = {
2698 'blurRad': std_dev * 2.0,
2699 'dist': math.hypot(dx, dy),
2700 }
2701 elif kind == 'glow':
2702 coordinates_px = {'rad': std_dev}
2703 else:
2704 raise ValueError(f'unsupported native filter kind {kind!r}')
2705
2706 coordinates: dict[str, int] = {}
2707 for attribute_name, value_px in coordinates_px.items():
2708 scaled = value_px * EMU_PER_PX
2709 if not math.isfinite(scaled):
2710 raise ValueError(
2711 f'DrawingML {attribute_name} must be finite after EMU mapping'
2712 )
2713 mapped = round(scaled)
2714 if not 0 <= mapped <= OOXML_COORDINATE_MAX:
2715 raise ValueError(
2716 f'DrawingML {attribute_name} must map within '
2717 f'0..{OOXML_COORDINATE_MAX}; got {mapped}'
2718 )
2719 coordinates[attribute_name] = mapped
2720 return coordinates
2721
2722
2723 def project_filter_errors(root: ET.Element) -> list[str]:
2724 """Validate filters against the native shadow/glow approximation."""
2725 definitions, _duplicates = project_definition_index(root)
2726 filters_by_id = {
2727 filter_id: elem
2728 for filter_id, elem in definitions.items()
2729 if _svg_element_tag(elem) == 'filter'
2730 }
2731 errors: set[str] = set()
2732 parents = {
2733 child: parent
2734 for parent in root.iter()
2735 for child in parent
2736 }
2737
2738 for elem in root.iter():
2739 tag = (_svg_element_tag(elem) or str(elem.tag)).lower()
2740 label = _transform_element_label(elem)
2741 style_values = parse_inline_style(elem.get('style'))
2742 if style_values.get('filter'):
2743 errors.add(
2744 f'{label} filter must use a direct filter="url(#id)" '
2745 'attribute; inline style filters are not supported'
2746 )
2747
2748 raw_filter = elem.get('filter')
2749 if raw_filter is None:
2750 continue
2751 if (
2752 tag not in PROJECT_FILTER_PUBLIC_TARGETS
2753 and not _is_compact_authored_preset_filter_target(elem)
2754 and not _is_imported_preset_preview_filter_target(elem, parents)
2755 and not is_picture_effect_carrier(elem)
2756 ):
2757 errors.add(
2758 f'{label} cannot use filter; supported native targets are '
2759 'rect, circle, image, path, text, a validated compact authored-'
2760 'preset shape, and an exact registered carrier group'
2761 )
2762 if tag == 'image' and elem.get('clip-path') is not None:
2763 errors.add(
2764 f'{label} cannot combine filter and clip-path on the same '
2765 'image; put the filter on an exact single-image outer <g>'
2766 )
2767 match = re.fullmatch(r'url\(#([^)]+)\)', raw_filter.strip())
2768 if match is None:
2769 errors.add(
2770 f'{label} filter must be an exact local url(#id) reference; '
2771 f'got {raw_filter!r}'
2772 )
2773 continue
2774 filter_id = match.group(1)
2775 if filter_id not in filters_by_id:
2776 errors.add(
2777 f'{label} filter=url(#{filter_id}) has no matching direct '
2778 f'<defs><filter id="{filter_id}"> definition'
2779 )
2780
2781 for filter_id, filter_elem in filters_by_id.items():
2782 label = f'filter #{filter_id}'
2783 parameters_are_valid = True
2784 primitive_units = filter_elem.get('primitiveUnits')
2785 if primitive_units not in (None, 'userSpaceOnUse'):
2786 parameters_are_valid = False
2787 errors.add(
2788 f'{label} primitiveUnits must be userSpaceOnUse when '
2789 f'explicit; got {primitive_units!r}'
2790 )
2791 primitives = [
2792 _svg_element_tag(descendant) or str(descendant.tag)
2793 for descendant in filter_elem.iter()
2794 if descendant is not filter_elem
2795 ]
2796 unsupported = sorted(set(primitives) - PROJECT_FILTER_PRIMITIVES)
2797 if unsupported:
2798 errors.add(
2799 f'{label} uses unsupported filter primitive(s): '
2800 f'{", ".join(unsupported)}'
2801 )
2802 effect_primitives = [
2803 primitive
2804 for primitive in primitives
2805 if primitive in PROJECT_FILTER_EFFECT_PRIMITIVES
2806 ]
2807 if not effect_primitives:
2808 errors.add(f'{label} must contain feDropShadow or feGaussianBlur')
2809 elif len(effect_primitives) > 1:
2810 errors.add(
2811 f'{label} contains multiple shadow/glow primitives; one '
2812 'filter must map to exactly one native effect'
2813 )
2814 if any(
2815 _svg_element_tag(descendant) == 'feFuncA'
2816 and descendant.get('type') != 'linear'
2817 for descendant in filter_elem.iter()
2818 ):
2819 errors.add(f'{label} requires feFuncA type="linear"')
2820
2821 for primitive in filter_elem.iter():
2822 primitive_tag = _svg_element_tag(primitive)
2823 if primitive_tag in {'feDropShadow', 'feFlood'}:
2824 style_values = parse_inline_style(primitive.get('style'))
2825 if (
2826 primitive.get('flood-opacity') is None
2827 and 'flood-opacity' not in style_values
2828 ):
2829 parameters_are_valid = False
2830 errors.add(
2831 f'{label} <{primitive_tag}> requires explicit '
2832 'flood-opacity'
2833 )
2834 if (
2835 primitive_tag == 'feFuncA'
2836 and primitive.get('intercept') is not None
2837 ):
2838 parameters_are_valid = False
2839 errors.add(
2840 f'{label} <feFuncA> intercept is unsupported; project '
2841 'alpha transfer maps slope multiplication only'
2842 )
2843 if (
2844 primitive_tag == 'feGaussianBlur'
2845 and primitive.get('edgeMode') is not None
2846 ):
2847 parameters_are_valid = False
2848 errors.add(
2849 f'{label} <feGaussianBlur> edgeMode is unsupported by '
2850 'the native effect mapping'
2851 )
2852 numeric_attrs: tuple[tuple[str, bool, bool], ...] = ()
2853 if primitive_tag in {'feDropShadow', 'feGaussianBlur'}:
2854 numeric_attrs = (('stdDeviation', True, True),)
2855 elif primitive_tag == 'feOffset':
2856 numeric_attrs = (
2857 ('dx', False, False),
2858 ('dy', False, False),
2859 )
2860 elif primitive_tag == 'feFuncA':
2861 numeric_attrs = (('slope', True, True),)
2862 if primitive_tag == 'feDropShadow':
2863 numeric_attrs += (
2864 ('dx', False, True),
2865 ('dy', False, True),
2866 )
2867 for attribute_name, non_negative, required in numeric_attrs:
2868 raw_value = primitive.get(attribute_name)
2869 if raw_value is None:
2870 if required:
2871 parameters_are_valid = False
2872 errors.add(
2873 f'{label} <{primitive_tag}> requires explicit '
2874 f'{attribute_name}'
2875 )
2876 continue
2877 try:
2878 value = float(raw_value)
2879 except (TypeError, ValueError):
2880 value = math.nan
2881 if (
2882 not math.isfinite(value)
2883 or (non_negative and value < 0)
2884 or (
2885 primitive_tag == 'feFuncA'
2886 and attribute_name == 'slope'
2887 and value > 1
2888 )
2889 ):
2890 if attribute_name in {'stdDeviation', 'dx', 'dy'}:
2891 parameters_are_valid = False
2892 qualifier = (
2893 ' from 0 to 1'
2894 if primitive_tag == 'feFuncA'
2895 else ''
2896 )
2897 errors.add(
2898 f'{label} <{primitive_tag}> {attribute_name} must be a '
2899 f'finite number{qualifier}; got {raw_value!r}'
2900 )
2901 if len(effect_primitives) == 1 and parameters_are_valid:
2902 try:
2903 params = parse_project_filter_params(filter_elem)
2904 project_filter_drawingml_coordinates(params)
2905 except (TypeError, ValueError) as exc:
2906 errors.add(f'{label} {exc}')
2907 return sorted(errors)
2908
2909
2910 def _is_compact_authored_preset_filter_target(elem: ET.Element) -> bool:
2911 """Recognize one validated project-authored preset shape filter target."""
2912 if (
2913 _svg_element_tag(elem) != 'g'
2914 or elem.get('data-pptx-authoring') != 'preset'
2915 or elem.get('data-pptx-object') != 'shape'
2916 or elem.get('data-pptx-part') is not None
2917 ):
2918 return False
2919 from pptx_to_svg.preset_authoring import ( # Local to avoid layer coupling.
2920 authored_preset_encoding,
2921 validate_authored_preset_group,
2922 )
2923
2924 return (
2925 authored_preset_encoding(elem) == 'compact'
2926 and not validate_authored_preset_group(elem)
2927 )
2928
2929
2930 def _is_imported_preset_preview_filter_target(
2931 elem: ET.Element,
2932 parents: dict[ET.Element, ET.Element],
2933 ) -> bool:
2934 """Recognize the render-only aggregate filter on an imported preset.
2935
2936 DrawingML presets can contain several visible path layers but own one
2937 shape-level effect. The lossless importer therefore keeps the native
2938 filter on the hidden geometry carrier and mirrors the same reference onto
2939 its hash-locked preview group. The preview group is never exported as a
2940 separate PowerPoint object; other ordinary or authored ``<g filter>``
2941 forms remain outside the project contract.
2942 """
2943 if (
2944 _svg_element_tag(elem) != 'g'
2945 or elem.get('data-pptx-part') != 'geometry-preview'
2946 ):
2947 return False
2948 parent = parents.get(elem)
2949 if (
2950 parent is None
2951 or _svg_element_tag(parent) != 'g'
2952 or parent.get('data-pptx-object') not in {'shape', 'connector'}
2953 or not parent.get('data-pptx-prst')
2954 or not parent.get('data-pptx-frame')
2955 ):
2956 return False
2957 previews = [
2958 child
2959 for child in parent
2960 if child.get('data-pptx-part') == 'geometry-preview'
2961 ]
2962 if len(previews) != 1 or previews[0] is not elem:
2963 return False
2964 preview_children = list(elem)
2965 if not preview_children or any(
2966 _svg_element_tag(child) != 'path'
2967 or child.get('data-pptx-part') != 'geometry-detail'
2968 or len(child) != 0
2969 for child in preview_children
2970 ):
2971 return False
2972 carriers = [
2973 child
2974 for child in parent
2975 if child.get('data-pptx-part') == 'geometry'
2976 ]
2977 if len(carriers) != 1:
2978 return False
2979 carrier = carriers[0]
2980 if not (
2981 _svg_element_tag(carrier) == 'path'
2982 and carrier.get('visibility') == 'hidden'
2983 and carrier.get('pointer-events') == 'none'
2984 and carrier.get('data-pptx-object') == parent.get('data-pptx-object')
2985 and carrier.get('data-pptx-prst') == parent.get('data-pptx-prst')
2986 and carrier.get('data-pptx-frame') == parent.get('data-pptx-frame')
2987 and carrier.get('filter') == elem.get('filter')
2988 ):
2989 return False
2990 try:
2991 expected_hash = resolve_preset_preview_hash(parent)
2992 except ValueError:
2993 return False
2994 return (
2995 expected_hash is not None
2996 and svg_preset_preview_fingerprint(parent) == expected_hash
2997 )
2998
2999
3000 def is_picture_effect_carrier(elem: ET.Element) -> bool:
3001 """Recognize one effect carrier around exactly one clipped picture."""
3002 if (
3003 _svg_element_tag(elem) != 'g'
3004 or elem.get('data-pptx-object') == 'group'
3005 or re.fullmatch(
3006 r'url\(#([^)]+)\)',
3007 (elem.get('filter') or '').strip(),
3008 ) is None
3009 or elem.get('data-pptx-layer') not in {None, 'master', 'layout'}
3010 or any(
3011 elem.get(attribute) is not None
3012 for attribute in (
3013 'data-pptx-placeholder',
3014 'data-pptx-binding',
3015 'data-pptx-replace-with',
3016 'data-pptx-native',
3017 )
3018 )
3019 ):
3020 return False
3021 children = [
3022 child for child in elem
3023 if _svg_element_tag(child) not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
3024 ]
3025 if len(children) != 1:
3026 return False
3027 picture = children[0]
3028 if picture.get('filter') is not None:
3029 return False
3030 owner_kind = elem.get('data-pptx-object')
3031 if _svg_element_tag(picture) == 'image':
3032 if resolve_url_id(picture.get('clip-path', '')) is None:
3033 return False
3034 if owner_kind is None:
3035 return True
3036 shape_id = elem.get('data-pptx-shape-id')
3037 return (
3038 owner_kind == 'picture'
3039 and shape_id is not None
3040 and picture.get('data-pptx-object') == 'picture'
3041 and picture.get('data-pptx-shape-id') == shape_id
3042 )
3043 if (
3044 _svg_element_tag(picture) != 'svg'
3045 or owner_kind != 'picture'
3046 or picture.get('data-pptx-object') != 'picture'
3047 or picture.get('viewBox') is None
3048 or picture.get('preserveAspectRatio') != 'none'
3049 ):
3050 return False
3051 shape_id = elem.get('data-pptx-shape-id')
3052 if (
3053 shape_id is None
3054 or picture.get('data-pptx-shape-id') != shape_id
3055 ):
3056 return False
3057 crop_children = list(picture)
3058 return (
3059 len(crop_children) == 1
3060 and _svg_element_tag(crop_children[0]) == 'image'
3061 )
3062
3063
3064 def parse_hex_color(color_str: str) -> str | None:
3065 """Parse SVG color values to ``RRGGBB``, ignoring any alpha channel."""
3066 if color_str and color_str.strip().lower() == 'transparent':
3067 return None
3068 color, _alpha = parse_svg_color(color_str)
3069 return color
3070
3071
3072 def combine_opacity(*values: float | None) -> float | None:
3073 """Multiply opacity components, returning ``None`` when fully opaque."""
3074 combined = 1.0
3075 for value in values:
3076 if value is not None:
3077 combined *= max(0.0, min(1.0, value))
3078 return combined if combined < 1.0 else None
3079
3080
3081 def parse_stop_style(style_str: str) -> tuple[str | None, float]:
3082 """Parse a gradient stop's style attribute.
3083
3084 Args:
3085 style_str: Style string like 'stop-color:#XXX;stop-opacity:N'.
3086
3087 Returns:
3088 (color, opacity) tuple.
3089 """
3090 color = None
3091 color_alpha = 1.0
3092 stop_opacity = 1.0
3093 style_values = parse_inline_style(style_str)
3094 if not style_values:
3095 return color, stop_opacity
3096
3097 if 'stop-color' in style_values:
3098 color, color_alpha = parse_svg_color(style_values['stop-color'])
3099 if 'stop-opacity' in style_values:
3100 stop_opacity = parse_opacity(
3101 style_values['stop-opacity'],
3102 allow_percentage=True,
3103 )
3104
3105 return color, color_alpha * stop_opacity
3106
3107
3108 def resolve_url_id(url_str: str) -> str | None:
3109 """Extract ID from 'url(#someId)' reference."""
3110 if not url_str:
3111 return None
3112 m = re.match(r'url\(#([^)]+)\)', url_str.strip())
3113 return m.group(1) if m else None
3114
3115
3116 def get_effective_filter_id(elem: ET.Element, ctx: ConvertContext) -> str | None:
3117 """Get the effective filter ID for an element, including inherited context."""
3118 filt = elem.get('filter')
3119 if filt:
3120 return resolve_url_id(filt)
3121 return ctx.filter_id
3122
3123
3124 # ---------------------------------------------------------------------------
3125 # Font parsing
3126 # ---------------------------------------------------------------------------
3127
3128 def parse_font_family(font_family_str: str) -> dict[str, str]:
3129 """Parse CSS font-family into latin/ea typeface names.
3130
3131 Prioritizes Windows-available fonts since PPTX is primarily opened on
3132 Windows. macOS/Linux-only fonts are mapped via FONT_FALLBACK_WIN.
3133 """
3134 if not font_family_str:
3135 return {'latin': 'Segoe UI', 'ea': 'Microsoft YaHei'}
3136
3137 fonts = [f.strip().strip("'\"") for f in font_family_str.split(',')]
3138 latin_font = None
3139 ea_font = None
3140
3141 for font in fonts:
3142 if font in SYSTEM_FONTS:
3143 continue
3144 if font in GENERIC_FONT_MAP:
3145 resolved = GENERIC_FONT_MAP[font]
3146 latin_font = latin_font or resolved
3147 continue
3148
3149 win_font = FONT_FALLBACK_WIN.get(font, font)
3150 if font in EA_FONTS:
3151 ea_font = ea_font or win_font
3152 else:
3153 latin_font = latin_font or win_font
3154
3155 # PPT renders CJK text via latin typeface when ea doesn't match
3156 if not latin_font and ea_font:
3157 latin_font = ea_font
3158
3159 final_latin = latin_font or 'Segoe UI'
3160
3161 # EA must always be a CJK-capable font
3162 if not ea_font:
3163 ea_font = 'SimSun' if final_latin in _SERIF_LATIN else 'Microsoft YaHei'
3164
3165 return {'latin': final_latin, 'ea': ea_font}
3166
3167
3168 def unsafe_exported_font_faces(font_family_str: str) -> dict[str, str]:
3169 """Return resolved PPTX typefaces that require a custom installation."""
3170 return {
3171 role: family
3172 for role, family in parse_font_family(font_family_str).items()
3173 if family.strip().lower() not in PPT_SAFE_FONTS
3174 }
3175
3176
3177 def _is_han_char(ch: str) -> bool:
3178 """Return whether one character belongs to a Han ideograph block."""
3179 cp = ord(ch)
3180 return (
3181 0x3400 <= cp <= 0x4DBF
3182 or 0x4E00 <= cp <= 0x9FFF
3183 or 0xF900 <= cp <= 0xFAFF
3184 or 0x20000 <= cp <= 0x2EE5F
3185 or 0x30000 <= cp <= 0x323AF
3186 )
3187
3188
3189 def _is_hiragana_char(ch: str) -> bool:
3190 cp = ord(ch)
3191 return (
3192 0x3040 <= cp <= 0x309F
3193 or 0x1B001 <= cp <= 0x1B11F
3194 )
3195
3196
3197 def _is_katakana_char(ch: str) -> bool:
3198 cp = ord(ch)
3199 return (
3200 0x30A0 <= cp <= 0x30FF
3201 or 0x31F0 <= cp <= 0x31FF
3202 or 0xFF65 <= cp <= 0xFF9F
3203 or 0x1AFF0 <= cp <= 0x1AFFF
3204 or cp == 0x1B000
3205 or 0x1B120 <= cp <= 0x1B16F
3206 )
3207
3208
3209 def _is_hangul_char(ch: str) -> bool:
3210 cp = ord(ch)
3211 return (
3212 0x1100 <= cp <= 0x11FF
3213 or 0x3130 <= cp <= 0x318F
3214 or 0xA960 <= cp <= 0xA97F
3215 or 0xAC00 <= cp <= 0xD7AF
3216 or 0xD7B0 <= cp <= 0xD7FF
3217 or 0xFFA0 <= cp <= 0xFFDC
3218 )
3219
3220
3221 def is_cjk_char(ch: str) -> bool:
3222 """Return whether one character uses the project East Asian width model."""
3223 cp = ord(ch)
3224 return (
3225 _is_han_char(ch)
3226 or _is_hiragana_char(ch)
3227 or _is_katakana_char(ch)
3228 or _is_hangul_char(ch)
3229 or 0x2E80 <= cp <= 0x2FFF
3230 or 0x3000 <= cp <= 0x303F
3231 or 0x3100 <= cp <= 0x312F
3232 or 0x31A0 <= cp <= 0x31BF
3233 or 0x31C0 <= cp <= 0x31EF
3234 or 0xFF00 <= cp <= 0xFFEF
3235 )
3236
3237
3238 def _contains_codepoint_range(
3239 text: str,
3240 ranges: tuple[tuple[int, int], ...],
3241 ) -> bool:
3242 """Return whether text contains a code point in one of the ranges."""
3243 return any(
3244 start <= ord(ch) <= end
3245 for ch in text
3246 for start, end in ranges
3247 )
3248
3249
3250 def _default_language_for_script(
3251 default_language: str | None,
3252 bases: frozenset[str],
3253 fallback: str,
3254 ) -> str:
3255 """Prefer the project language when it belongs to the detected script."""
3256 if default_language and language_base(default_language) in bases:
3257 return default_language
3258 return fallback
3259
3260
3261 def text_has_rtl_characters(text: str) -> bool:
3262 """Return whether text contains a strong right-to-left character."""
3263 return any(unicodedata.bidirectional(ch) in {'R', 'AL'} for ch in text)
3264
3265
3266 def text_uses_rtl(text: str, default_language: str | None = None) -> bool:
3267 """Resolve paragraph direction from its first strong character or project."""
3268 for char in text:
3269 direction = unicodedata.bidirectional(char)
3270 if direction in {'R', 'AL'}:
3271 return True
3272 if direction == 'L':
3273 return False
3274 return bool(default_language and language_uses_rtl(default_language))
3275
3276
3277 def detect_text_lang(
3278 text: str,
3279 default_language: str | None = None,
3280 ) -> str:
3281 """Return a DrawingML language tag, preferring the project contract."""
3282 has_hangul = False
3283 has_kana = False
3284 has_east_asian_text = False
3285 for ch in text:
3286 has_hangul = has_hangul or _is_hangul_char(ch)
3287 has_kana = (
3288 has_kana
3289 or _is_hiragana_char(ch)
3290 or _is_katakana_char(ch)
3291 )
3292 has_east_asian_text = has_east_asian_text or is_cjk_char(ch)
3293 if has_hangul:
3294 return _default_language_for_script(
3295 default_language,
3296 frozenset({'ko'}),
3297 'ko-KR',
3298 )
3299 if has_kana:
3300 return _default_language_for_script(
3301 default_language,
3302 frozenset({'ja'}),
3303 'ja-JP',
3304 )
3305 if has_east_asian_text:
3306 return _default_language_for_script(
3307 default_language,
3308 frozenset({'zh', 'ja', 'ko'}),
3309 'zh-CN',
3310 )
3311 if _contains_codepoint_range(text, (
3312 (0x0600, 0x06FF),
3313 (0x0750, 0x077F),
3314 (0x08A0, 0x08FF),
3315 (0xFB50, 0xFDFF),
3316 (0xFE70, 0xFEFF),
3317 (0x1EE00, 0x1EEFF),
3318 )):
3319 return _default_language_for_script(
3320 default_language,
3321 frozenset({'ar', 'fa', 'ps', 'sd', 'ug', 'ur'}),
3322 'ar-SA',
3323 )
3324 if _contains_codepoint_range(text, (
3325 (0x0590, 0x05FF),
3326 (0xFB1D, 0xFB4F),
3327 )):
3328 return _default_language_for_script(
3329 default_language,
3330 frozenset({'he', 'yi'}),
3331 'he-IL',
3332 )
3333 if _contains_codepoint_range(text, (
3334 (0x0900, 0x097F),
3335 (0xA8E0, 0xA8FF),
3336 )):
3337 return _default_language_for_script(
3338 default_language,
3339 frozenset({'hi', 'mr', 'ne', 'sa'}),
3340 'hi-IN',
3341 )
3342 if _contains_codepoint_range(text, ((0x0E00, 0x0E7F),)):
3343 return _default_language_for_script(
3344 default_language,
3345 frozenset({'th'}),
3346 'th-TH',
3347 )
3348 if _contains_codepoint_range(text, (
3349 (0x0400, 0x052F),
3350 (0x1C80, 0x1C8F),
3351 (0x2DE0, 0x2DFF),
3352 (0xA640, 0xA69F),
3353 )):
3354 return _default_language_for_script(
3355 default_language,
3356 frozenset({'be', 'bg', 'kk', 'ky', 'mk', 'mn', 'ru', 'sr', 'uk'}),
3357 'ru-RU',
3358 )
3359 if _contains_codepoint_range(text, (
3360 (0x0370, 0x03FF),
3361 (0x1F00, 0x1FFF),
3362 )):
3363 return _default_language_for_script(
3364 default_language,
3365 frozenset({'el'}),
3366 'el-GR',
3367 )
3368 return default_language or 'en-US'
3369
3370
3371 def _is_grapheme_extend(ch: str) -> bool:
3372 """Return whether ``ch`` extends the preceding rendered character."""
3373 cp = ord(ch)
3374 return (
3375 unicodedata.category(ch) in {'Mn', 'Mc', 'Me'}
3376 or 0xFE00 <= cp <= 0xFE0F
3377 or 0xE0100 <= cp <= 0xE01EF
3378 or 0x1F3FB <= cp <= 0x1F3FF
3379 or 0xE0020 <= cp <= 0xE007F
3380 )
3381
3382
3383 def _is_regional_indicator(ch: str) -> bool:
3384 return 0x1F1E6 <= ord(ch) <= 0x1F1FF
3385
3386
3387 def _is_virama(ch: str) -> bool:
3388 name = unicodedata.name(ch, '')
3389 return (
3390 unicodedata.combining(ch) == 9
3391 or 'VIRAMA' in name
3392 or name.endswith(' SIGN HALANT')
3393 )
3394
3395
3396 def _is_emoji_base(ch: str) -> bool:
3397 cp = ord(ch)
3398 return 0x2600 <= cp <= 0x27BF or 0x1F000 <= cp <= 0x1FAFF
3399
3400
3401 def _unicode_script_key(ch: str) -> str | None:
3402 """Return the stable Unicode-name prefix used for project script joins."""
3403 name = unicodedata.name(ch, '')
3404 if not name:
3405 return None
3406 tokens = name.split()
3407 boundary_tokens = {
3408 'CONSONANT',
3409 'LETTER',
3410 'SIGN',
3411 'SYLLABLE',
3412 'VOWEL',
3413 }
3414 for index, token in enumerate(tokens):
3415 if index > 0 and token in boundary_tokens:
3416 return ' '.join(tokens[:index])
3417 if tokens[0] in {'MEETEI', 'OL', 'TAI'} and len(tokens) > 1:
3418 return ' '.join(tokens[:2])
3419 return tokens[0]
3420
3421
3422 def _virama_script_key(cluster: str, virama: str) -> str | None:
3423 virama_script = _unicode_script_key(virama)
3424 for ch in reversed(cluster):
3425 if not unicodedata.category(ch).startswith('L'):
3426 continue
3427 base_script = _unicode_script_key(ch)
3428 return base_script if base_script == virama_script else None
3429 return None
3430
3431
3432 def split_project_text_clusters(text: str) -> list[str]:
3433 """Split text into the rendered units used by project width estimates.
3434
3435 This intentionally implements only the Unicode joins that affect SVG to
3436 DrawingML tracking: combining marks, variation selectors, emoji modifiers,
3437 ZWJ sequences, regional-indicator pairs, and common virama conjuncts.
3438 """
3439 clusters: list[str] = []
3440 virama_script: str | None = None
3441 emoji_join = False
3442 for ch in text:
3443 if not clusters:
3444 clusters.append(ch)
3445 continue
3446
3447 cluster = clusters[-1]
3448 previous = cluster[-1]
3449 if ch == '\n' and previous == '\r':
3450 clusters[-1] += ch
3451 virama_script = None
3452 emoji_join = False
3453 elif _is_grapheme_extend(ch):
3454 if _is_virama(ch):
3455 virama_script = _virama_script_key(cluster, ch)
3456 clusters[-1] += ch
3457 elif ch == '\u200d':
3458 clusters[-1] += ch
3459 emoji_join = any(_is_emoji_base(item) for item in cluster)
3460 elif ch == '\u200c':
3461 clusters[-1] += ch
3462 virama_script = None
3463 emoji_join = False
3464 elif (
3465 virama_script is not None
3466 and unicodedata.category(ch).startswith('L')
3467 and _unicode_script_key(ch) == virama_script
3468 ):
3469 clusters[-1] += ch
3470 virama_script = None
3471 emoji_join = False
3472 elif emoji_join and _is_emoji_base(ch):
3473 clusters[-1] += ch
3474 emoji_join = False
3475 elif (
3476 len(cluster) == 1
3477 and _is_regional_indicator(cluster)
3478 and _is_regional_indicator(ch)
3479 ):
3480 clusters[-1] += ch
3481 else:
3482 clusters.append(ch)
3483 virama_script = None
3484 emoji_join = False
3485 return clusters
3486
3487
3488 def resolve_text_run_fonts(text: str, fonts: dict[str, str]) -> dict[str, str]:
3489 """Return DrawingML latin/ea/cs typefaces for one text run."""
3490 latin = fonts['latin']
3491 if any(is_cjk_char(ch) for ch in text):
3492 ea = fonts['ea']
3493 else:
3494 ea = latin
3495 return {'latin': latin, 'ea': ea, 'cs': latin}
3496
3497
3498 def _estimate_character_width(ch: str, font_size: float) -> float:
3499 if (
3500 0xFF00 <= ord(ch) <= 0xFFEF
3501 and unicodedata.east_asian_width(ch) == 'H'
3502 ):
3503 return font_size * 0.5
3504 if is_cjk_char(ch):
3505 return font_size
3506 if ch == ' ':
3507 return font_size * 0.3
3508 if ch in 'mMwWOQ%':
3509 return font_size * 0.75
3510 if ch in 'iIlj!|':
3511 return font_size * 0.3
3512 if ch.isdigit():
3513 # digits are tabular (uniform ~0.55em) in most UI fonts, including
3514 # '1' — classing it with 'il|' under-sizes the box and makes
3515 # renderers that ignore wrap="none" (LibreOffice) wrap the line
3516 return font_size * 0.55
3517 return font_size * 0.55
3518
3519
3520 def _estimate_grapheme_width(cluster: str, font_size: float) -> float:
3521 bases = [
3522 ch for ch in cluster
3523 if ch not in {'\u200c', '\u200d'} and not _is_grapheme_extend(ch)
3524 ]
3525 if not bases:
3526 return font_size * 0.55
3527 if (
3528 len(bases) > 1
3529 and all(_is_regional_indicator(ch) for ch in bases)
3530 ) or '\u20e3' in cluster or any(_is_emoji_base(ch) for ch in bases):
3531 return font_size
3532 return max(_estimate_character_width(ch, font_size) for ch in bases)
3533
3534
3535 def estimate_text_cluster_widths(
3536 text: str,
3537 font_size: float,
3538 font_weight: str = '400',
3539 ) -> list[float]:
3540 """Estimate each project text cluster without inserting tracking."""
3541 clusters = split_project_text_clusters(text)
3542 widths = [
3543 _estimate_grapheme_width(cluster, font_size)
3544 for cluster in clusters
3545 ]
3546 if font_weight in ('bold', '600', '700', '800', '900'):
3547 widths = [
3548 width if any(is_cjk_char(ch) for ch in cluster) else width * 1.05
3549 for cluster, width in zip(clusters, widths)
3550 ]
3551 return widths
3552
3553
3554 def estimate_text_width(text: str, font_size: float, font_weight: str = '400') -> float:
3555 """Estimate text width in SVG pixels."""
3556 return sum(estimate_text_cluster_widths(text, font_size, font_weight))
3557
3558
3559 def _xml_escape(text: str) -> str:
3560 """Escape XML special characters."""
3561 return (text.replace('&', '&amp;')
3562 .replace('<', '&lt;')
3563 .replace('>', '&gt;')
3564 .replace('"', '&quot;'))
3565
3565 lines PYTHON