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