返回 ppt-master
elements.py
1 """SVG element converters: rect, circle, line, path, polygon, polyline, text, image, ellipse."""
2
3 from __future__ import annotations
4
5 import base64
6 import binascii
7 import hashlib
8 import io
9 import math
10 import re
11 from dataclasses import dataclass
12 from pathlib import Path
13 from typing import Any
14 from urllib.parse import unquote_to_bytes
15 from xml.etree import ElementTree as ET
16
17 from pptx_shapes import (
18 CONNECTOR_PRESET_TYPES,
19 OOXML_COORDINATE_MAX,
20 OOXML_COORDINATE_MIN,
21 get_preset_registry,
22 has_relationship_attributes,
23 load_shape_type_values,
24 validate_ooxml_xfrm,
25 )
26 from pptx_effects import EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR
27 from hyperlink_contract import svg_hyperlink_href
28 from pptx_to_svg.preset_authoring import AUTHORING_ATTR, AUTHORING_VALUE
29 from resource_paths import (
30 resolve_external_image_reference,
31 svg_image_payload_error,
32 )
33
34 from .context import (
35 TEXT_FLOW_PRESERVE,
36 TEXT_FLOW_SPLIT,
37 ConvertContext,
38 ShapeResult,
39 )
40 from .hyperlinks import (
41 HYPERLINK_ACTION_KEY,
42 HYPERLINK_RID_KEY,
43 hyperlink_click_xml,
44 hyperlink_run_metadata,
45 )
46 from .theme_colors import color_node_xml
47 from .theme_fonts import theme_font_tokens
48 from .text_properties import (
49 drawingml_letter_spacing,
50 normalize_project_text_segments,
51 parse_project_baseline_shift,
52 parse_project_font_style,
53 parse_project_font_weight,
54 parse_project_letter_spacing,
55 parse_project_text_anchor,
56 parse_project_text_decoration,
57 resolve_project_xml_space,
58 )
59 from .utils import (
60 SVG_NS, XLINK_NS, ANGLE_UNIT, FONT_PX_TO_HUNDREDTHS_PT,
61 PROJECT_IMAGE_ASPECT_RATIO_ANCHORS,
62 px_to_emu, _f, _get_attr, parse_svg_length,
63 svg_length_x, svg_length_y, svg_length_size,
64 ctx_x, ctx_y, ctx_w, ctx_h,
65 rect_to_dml_xfrm,
66 combine_opacity, parse_hex_color, parse_svg_color,
67 resolve_project_text_image_fill, resolve_url_id, get_effective_filter_id,
68 parse_inline_style, parse_font_family, is_cjk_char,
69 detect_text_lang, estimate_text_cluster_widths, font_px_to_hpt,
70 resolve_text_run_fonts, split_project_text_clusters,
71 text_has_rtl_characters, text_uses_rtl,
72 is_thick_circle_shorthand, parse_project_geometry_length,
73 is_canonical_project_geometry_length,
74 parse_project_image_aspect_ratio,
75 parse_project_opacity,
76 parse_project_stroke_dasharray,
77 quantize_ooxml_alpha,
78 project_definition_index,
79 matrix_multiply, parse_transform_matrix, parse_transform_operations,
80 transform_point, _xml_escape,
81 )
82 from .styles import (
83 build_solid_fill, build_gradient_fill,
84 build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect,
85 get_element_opacity, get_fill_opacity, get_stroke_opacity,
86 )
87 from .paths import (
88 PathCommand, parse_svg_path, parse_svg_points, svg_path_to_absolute,
89 normalize_path_commands, path_commands_to_drawingml,
90 transform_path_commands,
91 )
92
93
94 def _resolve_external_image(svg_dir: Path, href: str) -> Path:
95 """Resolve a non-data-URI image href to a file on disk.
96
97 Search order: next to the SVG (``svg_output/``), the project root, the
98 project's ``images/`` (the single runtime image pool — template-bundled
99 bitmaps plus AI / web / user images all live here), then ``templates/``
100 (legacy flat-copied template assets). Raises ``FileNotFoundError`` if none
101 of these exist.
102 """
103 candidate = resolve_external_image_reference(svg_dir, href)
104 if candidate is not None:
105 return candidate
106 raise FileNotFoundError(f'External image not found: {href}')
107
108
109 _PROJECT_IMAGE_FORMATS = {
110 'bmp': 'bmp',
111 'emf': 'emf',
112 'gif': 'gif',
113 'jpeg': 'jpg',
114 'jpg': 'jpg',
115 'png': 'png',
116 'svg': 'svg',
117 'svg+xml': 'svg',
118 'tif': 'tif',
119 'tiff': 'tiff',
120 'webp': 'webp',
121 'wmf': 'wmf',
122 'x-emf': 'emf',
123 'x-wmf': 'wmf',
124 }
125 _PIL_IMAGE_FORMATS = {
126 'bmp': 'BMP',
127 'gif': 'GIF',
128 'jpg': 'JPEG',
129 'png': 'PNG',
130 'tif': 'TIFF',
131 'tiff': 'TIFF',
132 'webp': 'WEBP',
133 }
134
135
136 def _normalize_project_image_format(raw: str) -> str | None:
137 return _PROJECT_IMAGE_FORMATS.get(raw.strip().lower().lstrip('.'))
138
139
140 def _little_uint(data: bytes, offset: int, size: int) -> int:
141 return int.from_bytes(data[offset:offset + size], 'little', signed=False)
142
143
144 def _valid_emf_payload(data: bytes) -> bool:
145 """Validate the EMF header and complete bounded record stream."""
146 if len(data) < 88:
147 return False
148 header_size = _little_uint(data, 4, 4)
149 total_size = _little_uint(data, 48, 4)
150 record_count = _little_uint(data, 52, 4)
151 header_palette_entries = _little_uint(data, 68, 4)
152 if (
153 _little_uint(data, 0, 4) != 1
154 or data[40:44] != b' EMF'
155 or header_size < 88
156 or header_size > total_size
157 or total_size != len(data)
158 or record_count < 1
159 ):
160 return False
161
162 offset = 0
163 count = 0
164 last_type = 0
165 last_size = 0
166 while offset < total_size:
167 if offset + 8 > total_size:
168 return False
169 record_type = _little_uint(data, offset, 4)
170 record_size = _little_uint(data, offset + 4, 4)
171 if record_size < 8 or record_size % 4 or offset + record_size > total_size:
172 return False
173 record_end = offset + record_size
174 if count == 0 and (record_type != 1 or record_size != header_size):
175 return False
176 if count > 0 and record_type == 1:
177 return False
178 if record_type == 14:
179 if (
180 record_size < 20
181 or record_end != total_size
182 or _little_uint(data, record_end - 4, 4) != record_size
183 ):
184 return False
185 palette_entries = _little_uint(data, offset + 8, 4)
186 palette_offset = _little_uint(data, offset + 12, 4)
187 if (
188 palette_entries != header_palette_entries
189 or palette_entries and (
190 palette_offset < 16
191 or palette_offset + palette_entries * 4 > record_size - 4
192 )
193 ):
194 return False
195 offset = record_end
196 count += 1
197 last_type = record_type
198 last_size = record_size
199 return (
200 offset == total_size
201 # MS-EMF counts all records. LibreOffice-generated EMF files in the
202 # wild count records after the header, so retain that interoperable
203 # spelling while keeping the complete stream bounded.
204 and count in {record_count, record_count + 1}
205 and last_type == 14
206 and last_size >= 20
207 )
208
209
210 def _valid_wmf_payload(data: bytes) -> bool:
211 """Validate a standard or placeable WMF header and record stream."""
212 meta_offset = 0
213 if data.startswith(b'\xd7\xcd\xc6\x9a'):
214 if len(data) < 40:
215 return False
216 checksum = 0
217 for offset in range(0, 20, 2):
218 checksum ^= _little_uint(data, offset, 2)
219 if checksum != _little_uint(data, 20, 2):
220 return False
221 meta_offset = 22
222 if len(data) < meta_offset + 24:
223 return False
224 meta_type = _little_uint(data, meta_offset, 2)
225 header_words = _little_uint(data, meta_offset + 2, 2)
226 version = _little_uint(data, meta_offset + 4, 2)
227 total_words = _little_uint(data, meta_offset + 6, 4)
228 max_record_words = _little_uint(data, meta_offset + 12, 4)
229 if (
230 meta_type not in {1, 2}
231 or header_words != 9
232 or version not in {0x0100, 0x0300}
233 or total_words < 12
234 or max_record_words < 3
235 ):
236 return False
237 total_end = meta_offset + total_words * 2
238 if total_end != len(data):
239 return False
240
241 offset = meta_offset + header_words * 2
242 last_function = -1
243 last_record_words = 0
244 observed_max_record_words = 0
245 while offset < total_end:
246 if offset + 6 > total_end:
247 return False
248 record_words = _little_uint(data, offset, 4)
249 function = _little_uint(data, offset + 4, 2)
250 if (
251 record_words < 3
252 or record_words > max_record_words
253 or offset + record_words * 2 > total_end
254 ):
255 return False
256 record_end = offset + record_words * 2
257 if function == 0 and (record_words != 3 or record_end != total_end):
258 return False
259 offset = record_end
260 last_function = function
261 last_record_words = record_words
262 observed_max_record_words = max(
263 observed_max_record_words,
264 record_words,
265 )
266 return (
267 offset == total_end
268 and last_function == 0
269 and last_record_words == 3
270 and observed_max_record_words == max_record_words
271 )
272
273
274 def _valid_project_image_payload(img_format: str, img_data: bytes) -> bool:
275 """Return whether bytes are a supported image of the declared format."""
276 if not img_data:
277 return False
278 if img_format == 'svg':
279 return svg_image_payload_error(img_data) is None
280 if img_format == 'emf':
281 return _valid_emf_payload(img_data)
282 if img_format == 'wmf':
283 return _valid_wmf_payload(img_data)
284
285 expected = _PIL_IMAGE_FORMATS.get(img_format)
286 if expected is None:
287 return False
288 try:
289 from PIL import Image, UnidentifiedImageError # type: ignore
290 except ImportError:
291 return False
292 try:
293 with Image.open(io.BytesIO(img_data)) as image:
294 actual = (image.format or '').upper()
295 image.verify()
296 except (UnidentifiedImageError, OSError, ValueError, SyntaxError):
297 return False
298 return actual == expected
299
300
301 def _decode_data_image_uri(href: str) -> tuple[str, bytes] | None:
302 """Decode and validate one closed-project image data URI."""
303 if not href.startswith('data:') or ',' not in href:
304 return None
305
306 header, payload = href.split(',', 1)
307 match = re.fullmatch(
308 r'data:image/([A-Za-z0-9.+-]+)(?:;[^;,]*)*?(?:;base64)?',
309 header,
310 flags=re.IGNORECASE,
311 )
312 if not match:
313 return None
314
315 img_format = _normalize_project_image_format(match.group(1))
316 if img_format is None:
317 return None
318
319 is_base64 = any(
320 part.strip().lower() == 'base64'
321 for part in header.split(';')[1:]
322 )
323 try:
324 if is_base64:
325 img_data = base64.b64decode(payload, validate=True)
326 else:
327 img_data = unquote_to_bytes(payload)
328 except (ValueError, binascii.Error):
329 return None
330 if not _valid_project_image_payload(img_format, img_data):
331 return None
332 return img_format, img_data
333
334
335 @dataclass(frozen=True)
336 class ProjectImageSource:
337 """Validated bytes and package extension for one SVG image reference."""
338
339 img_format: str
340 img_data: bytes
341
342
343 def _project_image_href(elem: ET.Element) -> str:
344 href_keys = tuple(
345 key for key in ('href', f'{{{XLINK_NS}}}href')
346 if key in elem.attrib
347 )
348 if len(href_keys) != 1:
349 raise ValueError('requires exactly one href or xlink:href')
350 href = elem.get(href_keys[0])
351 if href is None or not href.strip():
352 raise ValueError('href cannot be empty')
353 return href
354
355
356 def load_project_image_source(
357 elem: ET.Element,
358 svg_dir: Path | None,
359 ) -> ProjectImageSource:
360 """Load one exact SVG image source or raise a contract error."""
361 if elem.tag != f'{{{SVG_NS}}}image':
362 raise ValueError('expected an SVG-namespace <image> element')
363 href = _project_image_href(elem)
364 if href.startswith('data:'):
365 decoded = _decode_data_image_uri(href)
366 if decoded is None:
367 raise ValueError(
368 'data URI must contain a supported, non-empty image with '
369 'valid encoding and bytes'
370 )
371 img_format, img_data = decoded
372 return ProjectImageSource(img_format, img_data)
373
374 if svg_dir is None:
375 raise ValueError('external image requires an SVG directory context')
376 try:
377 img_path = _resolve_external_image(svg_dir, href)
378 except FileNotFoundError as exc:
379 raise ValueError(str(exc)) from exc
380 img_format = _normalize_project_image_format(img_path.suffix)
381 if img_format is None:
382 raise ValueError(
383 f'external image has unsupported file extension {img_path.suffix!r}'
384 )
385 try:
386 img_data = img_path.read_bytes()
387 except OSError as exc:
388 raise ValueError(f'cannot read external image {href!r}: {exc}') from exc
389 if not _valid_project_image_payload(img_format, img_data):
390 raise ValueError(
391 f'external image {href!r} is empty, corrupt, or does not match '
392 f'its {img_path.suffix} extension'
393 )
394 return ProjectImageSource(img_format, img_data)
395
396
397 def project_image_errors(
398 root: ET.Element,
399 svg_dir: Path | None,
400 *,
401 allow_template_placeholders: bool = False,
402 ) -> list[str]:
403 """Return source and frame errors for exact SVG image elements."""
404 errors: list[str] = []
405 for elem in root.iter():
406 if elem.tag.rsplit('}', 1)[-1] != 'image':
407 continue
408 label = _element_contract_label(elem)
409 if elem.tag != f'{{{SVG_NS}}}image':
410 errors.append(
411 f'{label} must use the SVG namespace '
412 f'{SVG_NS!r}'
413 )
414 continue
415 style_values = parse_inline_style(elem.get('style'))
416 for attribute in ('width', 'height'):
417 raw = style_values.get(attribute)
418 if raw is None:
419 raw = elem.get(attribute)
420 if raw is None:
421 errors.append(
422 f'{label} requires explicit positive {attribute}'
423 )
424 continue
425 try:
426 value = parse_project_geometry_length(raw, attribute)
427 except ValueError:
428 # The shared geometry-length contract owns syntax diagnostics.
429 continue
430 if value <= 0:
431 errors.append(
432 f'{label} {attribute} must be positive; got {raw!r}'
433 )
434 try:
435 raw_href = _project_image_href(elem)
436 except ValueError as exc:
437 errors.append(f'{label} invalid image source: {exc}')
438 continue
439 if (
440 allow_template_placeholders
441 and '{{' in raw_href
442 and '}}' in raw_href
443 ):
444 continue
445 try:
446 load_project_image_source(elem, svg_dir)
447 except ValueError as exc:
448 errors.append(f'{label} invalid image source: {exc}')
449 return sorted(errors)
450
451
452 def _wrap_shape(
453 shape_id: int, name: str,
454 off_x: int, off_y: int,
455 ext_cx: int, ext_cy: int,
456 geom_xml: str, fill_xml: str, stroke_xml: str,
457 effect_xml: str = '', extra_xml: str = '',
458 rot: int = 0,
459 xfrm_attr: str = '',
460 ) -> str:
461 """Wrap DrawingML content into a <p:sp> shape element."""
462 rot_attr = f' rot="{rot}"' if rot else ''
463 xfrm_attrs = f'{xfrm_attr}{rot_attr}'
464 return f'''<p:sp>
465 <p:nvSpPr>
466 <p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
467 <p:cNvSpPr/><p:nvPr/>
468 </p:nvSpPr>
469 <p:spPr>
470 <a:xfrm{xfrm_attrs}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
471 {geom_xml}
472 {fill_xml}
473 {stroke_xml}
474 {effect_xml}
475 </p:spPr>
476 {extra_xml}
477 </p:sp>'''
478
479
480 def _wrap_connector(
481 shape_id: int,
482 name: str,
483 off_x: int,
484 off_y: int,
485 ext_cx: int,
486 ext_cy: int,
487 geom_xml: str,
488 fill_xml: str,
489 stroke_xml: str,
490 effect_xml: str = '',
491 rot: int = 0,
492 xfrm_attr: str = '',
493 connection_xml: str = '',
494 extra_xml: str = '',
495 ) -> str:
496 """Wrap DrawingML content into a native ``p:cxnSp`` connector."""
497 rot_attr = f' rot="{rot}"' if rot else ''
498 xfrm_attrs = f'{xfrm_attr}{rot_attr}'
499 return f'''<p:cxnSp>
500 <p:nvCxnSpPr>
501 <p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
502 <p:cNvCxnSpPr>{connection_xml}</p:cNvCxnSpPr><p:nvPr/>
503 </p:nvCxnSpPr>
504 <p:spPr>
505 <a:xfrm{xfrm_attrs}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
506 {geom_xml}
507 {fill_xml}
508 {stroke_xml}
509 {effect_xml}
510 </p:spPr>
511 {extra_xml}
512 </p:cxnSp>'''
513
514
515 def _wrap_geometry_object(
516 elem: ET.Element,
517 ctx: ConvertContext,
518 shape_id: int,
519 name: str,
520 off_x: int,
521 off_y: int,
522 ext_cx: int,
523 ext_cy: int,
524 geom_xml: str,
525 fill_xml: str,
526 stroke_xml: str,
527 effect_xml: str = '',
528 xfrm_attr: str = '',
529 ) -> str:
530 """Wrap a semantic leaf as a shape or connector without guessing."""
531 name = elem.get('data-pptx-shape-name') or name
532 shape_style_xml = _decode_shape_style(elem)
533 object_kind = elem.get('data-pptx-object')
534 if object_kind != 'connector':
535 return _wrap_shape(
536 shape_id,
537 name,
538 off_x,
539 off_y,
540 ext_cx,
541 ext_cy,
542 geom_xml,
543 fill_xml,
544 stroke_xml,
545 effect_xml,
546 extra_xml=shape_style_xml,
547 xfrm_attr=xfrm_attr,
548 )
549
550 prst = elem.get('data-pptx-prst')
551 is_custom = elem.get('data-pptx-geometry-kind') == 'custom'
552 if prst is None and not is_custom:
553 raise ValueError(
554 'data-pptx-object="connector" requires preset or preserved '
555 'custom geometry'
556 )
557 return _wrap_connector(
558 shape_id,
559 name,
560 off_x,
561 off_y,
562 ext_cx,
563 ext_cy,
564 geom_xml,
565 fill_xml,
566 stroke_xml,
567 effect_xml,
568 xfrm_attr=xfrm_attr,
569 connection_xml=_connector_connection_xml(elem, ctx),
570 extra_xml=shape_style_xml,
571 )
572
573
574 def _decode_shape_style(elem: ET.Element) -> str:
575 encoded = elem.get('data-pptx-shape-style')
576 if not encoded:
577 return ''
578 try:
579 raw = base64.b64decode(encoded, validate=True)
580 style = ET.fromstring(raw)
581 decoded = raw.decode('utf-8')
582 except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
583 raise ValueError(f'Invalid shape-style metadata: {exc}') from exc
584 if style.tag != (
585 '{http://schemas.openxmlformats.org/presentationml/2006/main}style'
586 ):
587 raise ValueError('Shape-style metadata payload must be p:style')
588 if has_relationship_attributes(style):
589 raise ValueError(
590 'Shape-style metadata must not contain relationship attributes'
591 )
592 return decoded
593
594
595 def _connector_connection_xml(elem: ET.Element, ctx: ConvertContext) -> str:
596 """Restore connector endpoint attachment using the reserved source id map."""
597 parts: list[str] = []
598 for endpoint, tag in (('start', 'stCxn'), ('end', 'endCxn')):
599 raw_shape_id = elem.get(f'data-pptx-{endpoint}-shape-id')
600 raw_site = elem.get(f'data-pptx-{endpoint}-site')
601 if raw_shape_id is None and raw_site is None:
602 continue
603 if raw_shape_id is None or raw_site is None:
604 raise ValueError(
605 f'Connector {endpoint} endpoint requires both shape-id and site'
606 )
607 target_scope = (
608 elem.get(f'data-pptx-{endpoint}-shape-scope')
609 or elem.get('data-pptx-shape-scope')
610 or 'slide'
611 )
612 target_id = ctx.reference_shape_id(raw_shape_id, target_scope)
613 try:
614 site = int(raw_site)
615 except ValueError as exc:
616 raise ValueError(
617 f'Invalid connector {endpoint} site {raw_site!r}'
618 ) from exc
619 if site < 0 or site > 0xFFFFFFFF:
620 raise ValueError(
621 f'Connector {endpoint} site is outside unsigned integer range: {site}'
622 )
623 parts.append(f'<a:{tag} id="{target_id}" idx="{site}"/>')
624 return ''.join(parts)
625
626
627 def _claim_element_shape_id(elem: ET.Element, ctx: ConvertContext) -> int:
628 return ctx.claim_shape_id(
629 elem.get('data-pptx-shape-id'),
630 elem.get('data-pptx-shape-scope'),
631 )
632
633
634 def _context_transform_matrix(ctx: ConvertContext) -> tuple[float, float, float, float, float, float]:
635 """Return the current context as a full SVG affine matrix."""
636 if ctx.use_transform_matrix:
637 return ctx.transform_matrix
638 return (
639 ctx.scale_x, 0.0,
640 0.0, ctx.scale_y,
641 ctx.translate_x, ctx.translate_y,
642 )
643
644
645 def _combined_transform_matrix(
646 ctx: ConvertContext,
647 transform: str | None,
648 ) -> tuple[float, float, float, float, float, float]:
649 """Compose context transform with an element-level transform attribute."""
650 matrix = _context_transform_matrix(ctx)
651 if transform:
652 matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
653 return matrix
654
655
656 def _uses_full_transform(ctx: ConvertContext, transform: str | None) -> bool:
657 return ctx.use_transform_matrix or bool(transform)
658
659
660 def _transformed_point(
661 ctx: ConvertContext,
662 x: float,
663 y: float,
664 transform: str | None,
665 ) -> tuple[float, float]:
666 if _uses_full_transform(ctx, transform):
667 return transform_point(_combined_transform_matrix(ctx, transform), x, y)
668 return ctx_x(x, ctx), ctx_y(y, ctx)
669
670
671 def _shape_xfrm_from_svg_rect(
672 ctx: ConvertContext,
673 raw_x: float,
674 raw_y: float,
675 raw_w: float,
676 raw_h: float,
677 resolved_x: float,
678 resolved_y: float,
679 resolved_w: float,
680 resolved_h: float,
681 transform: str | None,
682 *,
683 preserve_degenerate_axes: bool = False,
684 ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
685 """Build DrawingML xfrm data for an SVG rectangle-like element."""
686 if _uses_full_transform(ctx, transform):
687 return rect_to_dml_xfrm(
688 raw_x, raw_y, raw_w, raw_h,
689 _combined_transform_matrix(ctx, transform),
690 preserve_degenerate_axes=preserve_degenerate_axes,
691 )
692
693 off_x = px_to_emu(resolved_x)
694 off_y = px_to_emu(resolved_y)
695 ext_cx = px_to_emu(resolved_w)
696 ext_cy = px_to_emu(resolved_h)
697 return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
698
699
700 # ---------------------------------------------------------------------------
701 # rect
702 # ---------------------------------------------------------------------------
703
704 # Cubic-Bézier control distance for approximating a quarter circle / ellipse.
705 # Distance from corner to control point along the tangent, expressed as a
706 # fraction of the radius. Standard "magic number" for a 90° arc (max error
707 # ~0.027% of the radius).
708 _BEZIER_QUARTER_K = 0.5522847498
709
710
711 # The hash-locked shared catalog is the single source of truth for the 187
712 # ECMA-376 ``ST_ShapeType`` values. Loading it here makes exporter validation
713 # fail closed if the catalog is missing, corrupt, or incomplete.
714 PPTX_PRESET_SHAPE_TYPES = frozenset(load_shape_type_values())
715
716 _PPTX_AV_PREFIX = 'data-pptx-av-'
717 _PPTX_GUIDE_NAME_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_.-]{0,63}')
718 _PPTX_VAL_FORMULA_RE = re.compile(r'val[\t ]+([+-]?\d+)')
719 def _parse_preset_geometry_metadata(
720 elem: ET.Element,
721 ) -> tuple[str | None, list[tuple[str, str]], tuple[float, float, float, float] | None]:
722 """Parse and validate rendering-neutral preset geometry metadata."""
723 status = (elem.get('data-pptx-geometry-status') or '').strip()
724 authoring = elem.get(AUTHORING_ATTR)
725 if authoring not in {None, AUTHORING_VALUE}:
726 raise ValueError(f'Unsupported {AUTHORING_ATTR} value {authoring!r}')
727 if authoring == AUTHORING_VALUE:
728 object_kind = elem.get('data-pptx-object')
729 if object_kind not in {'shape', 'connector'}:
730 raise ValueError(
731 'Authored preset metadata requires data-pptx-object='
732 '"shape" or "connector"'
733 )
734 preset = elem.get('data-pptx-prst')
735 if preset is None:
736 raise ValueError('Authored preset metadata requires data-pptx-prst')
737 if preset in CONNECTOR_PRESET_TYPES and object_kind != 'connector':
738 raise ValueError(
739 f'Connector preset {preset!r} requires '
740 'data-pptx-object="connector"'
741 )
742 if object_kind == 'connector' and preset not in CONNECTOR_PRESET_TYPES:
743 raise ValueError(
744 f'Authored connector requires a connector preset, got {preset!r}'
745 )
746 if elem.get('data-pptx-frame') is None:
747 raise ValueError('Authored preset metadata requires data-pptx-frame')
748 if status not in {'', 'exact', 'unsupported'}:
749 raise ValueError(
750 f'Unsupported data-pptx-geometry-status {status!r}; '
751 'expected exact or unsupported'
752 )
753 raw_reason = elem.get('data-pptx-geometry-reason')
754 if raw_reason is not None and status != 'unsupported':
755 raise ValueError(
756 'data-pptx-geometry-reason requires '
757 'data-pptx-geometry-status="unsupported"'
758 )
759 if status == 'unsupported':
760 reason = (raw_reason or 'unspecified').strip()
761 raise ValueError(f'Unsupported source PPTX geometry: {reason}')
762
763 prst = elem.get('data-pptx-prst')
764 allowed_guide_names: frozenset[str] = frozenset()
765 if prst is not None:
766 if prst != prst.strip() or prst not in PPTX_PRESET_SHAPE_TYPES:
767 raise ValueError(f'Unknown or invalid data-pptx-prst {prst!r}')
768 allowed_guide_names = frozenset(
769 guide.name
770 for guide in get_preset_registry().get(prst).adjustments
771 )
772
773 guide_formulas: dict[str, str] = {}
774 for attr_name, raw_fmla in elem.attrib.items():
775 if not attr_name.startswith(_PPTX_AV_PREFIX):
776 continue
777 if prst is None:
778 raise ValueError(f'{attr_name} requires data-pptx-prst')
779 guide_name = attr_name[len(_PPTX_AV_PREFIX):]
780 if not _PPTX_GUIDE_NAME_RE.fullmatch(guide_name):
781 raise ValueError(f'Invalid preset adjustment guide name {guide_name!r}')
782 if guide_name not in allowed_guide_names:
783 raise ValueError(
784 f'Preset {prst!r} has no adjustment guide named {guide_name!r}'
785 )
786 formula = raw_fmla.strip()
787 if not formula:
788 raise ValueError(f'{attr_name} must not be empty')
789 match = _PPTX_VAL_FORMULA_RE.fullmatch(formula)
790 if match is not None:
791 value = int(match.group(1))
792 if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX:
793 raise ValueError(
794 f'{attr_name} value {value} is outside OOXML coordinate range'
795 )
796 guide_formulas[guide_name] = formula
797
798 # Compatibility for SVGs emitted before the generic ``data-pptx-av-*``
799 # contract. New imports always use the canonical full-formula attributes.
800 if prst == 'round2SameRect':
801 guide_names = set(guide_formulas)
802 for guide_name, default in (('adj1', 16667), ('adj2', 0)):
803 legacy_name = f'data-pptx-{guide_name}'
804 if guide_name in guide_names or elem.get(legacy_name) is None:
805 continue
806 raw_value = elem.get(legacy_name, str(default))
807 try:
808 value = int(float(raw_value))
809 except ValueError as exc:
810 raise ValueError(f'{legacy_name} must be numeric, got {raw_value!r}') from exc
811 value = max(0, min(100000, value))
812 guide_formulas[guide_name] = f'val {value}'
813
814 guides: list[tuple[str, str]] = []
815 if prst is not None and guide_formulas:
816 registry = get_preset_registry()
817 try:
818 evaluated = registry.evaluate(
819 prst,
820 100000,
821 100000,
822 adjustments=guide_formulas,
823 )
824 except ValueError as exc:
825 raise ValueError(
826 f'Invalid adjustment formula for preset {prst!r}: {exc}'
827 ) from exc
828 for name, value in evaluated.adjustments.items():
829 if (
830 name in guide_formulas
831 and not OOXML_COORDINATE_MIN
832 <= value
833 <= OOXML_COORDINATE_MAX
834 ):
835 raise ValueError(
836 f'data-pptx-av-{name} evaluates outside OOXML coordinate range'
837 )
838 guides = [
839 (guide.name, guide_formulas[guide.name])
840 for guide in registry.get(prst).adjustments
841 if guide.name in guide_formulas
842 ]
843
844 frame = None
845 raw_frame = elem.get('data-pptx-frame')
846 if raw_frame is not None:
847 parts = re.split(r'[\s,]+', raw_frame.strip())
848 if len(parts) != 4:
849 raise ValueError(
850 'data-pptx-frame must contain exactly four numbers: x y width height'
851 )
852 try:
853 frame = tuple(float(part) for part in parts)
854 except ValueError as exc:
855 raise ValueError(f'Invalid data-pptx-frame {raw_frame!r}') from exc
856 if not all(math.isfinite(value) for value in frame):
857 raise ValueError(f'data-pptx-frame must contain finite numbers, got {raw_frame!r}')
858 is_connector = (
859 elem.get('data-pptx-object') == 'connector'
860 or prst in CONNECTOR_PRESET_TYPES
861 )
862 if is_connector:
863 if frame[2] < 0 or frame[3] < 0 or (frame[2] == 0 and frame[3] == 0):
864 raise ValueError(
865 'Connector data-pptx-frame dimensions must be non-negative '
866 f'and not both zero, got {raw_frame!r}'
867 )
868 elif frame[2] <= 0 or frame[3] <= 0:
869 raise ValueError(
870 f'data-pptx-frame width and height must be positive, got {raw_frame!r}'
871 )
872 validate_ooxml_xfrm(
873 px_to_emu(frame[0]),
874 px_to_emu(frame[1]),
875 px_to_emu(frame[2]),
876 px_to_emu(frame[3]),
877 )
878
879 return prst, guides, frame
880
881
882 def validate_preset_geometry_metadata(elem: ET.Element) -> list[str]:
883 """Return native shape metadata errors for authoring-time validation."""
884 errors: list[str] = []
885 try:
886 _parse_preset_geometry_metadata(elem)
887 except ValueError as exc:
888 errors.append(str(exc))
889 if elem.get('data-pptx-custgeom') is not None:
890 try:
891 _build_preserved_custom_geom(elem)
892 except ValueError as exc:
893 errors.append(str(exc))
894 if elem.get('data-pptx-shape-style') is not None:
895 try:
896 _decode_shape_style(elem)
897 except ValueError as exc:
898 errors.append(str(exc))
899 raw_shape_id = elem.get('data-pptx-shape-id')
900 if raw_shape_id is not None:
901 try:
902 shape_id = int(raw_shape_id)
903 except ValueError:
904 errors.append(f'Invalid data-pptx-shape-id {raw_shape_id!r}')
905 else:
906 if shape_id < 2 or shape_id > 0xFFFFFFFF:
907 errors.append(
908 'data-pptx-shape-id must be between 2 and 4294967295'
909 )
910 scope = elem.get('data-pptx-shape-scope')
911 if scope is not None and re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None:
912 errors.append(f'Invalid data-pptx-shape-scope {scope!r}')
913 for endpoint in ('start', 'end'):
914 target = elem.get(f'data-pptx-{endpoint}-shape-id')
915 site = elem.get(f'data-pptx-{endpoint}-site')
916 if (target is None) != (site is None):
917 errors.append(
918 f'Connector {endpoint} endpoint requires both shape-id and site'
919 )
920 if target is not None:
921 try:
922 target_id = int(target)
923 site_id = int(site or '')
924 except ValueError:
925 errors.append(f'Invalid connector {endpoint} endpoint metadata')
926 else:
927 if target_id < 2 or target_id > 0xFFFFFFFF:
928 errors.append(f'Connector {endpoint} shape-id is out of range')
929 if site_id < 0 or site_id > 0xFFFFFFFF:
930 errors.append(f'Connector {endpoint} site is out of range')
931 return errors
932
933
934 def _build_preset_geom_from_meta(elem: ET.Element) -> str | None:
935 """Build validated native DrawingML preset geometry from SVG metadata."""
936 prst, guides, _frame = _parse_preset_geometry_metadata(elem)
937 if prst is None:
938 return None
939 if not guides:
940 return f'<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>'
941 guide_xml = ''.join(
942 f'<a:gd name="{_xml_escape(name)}" fmla="{_xml_escape(fmla)}"/>'
943 for name, fmla in guides
944 )
945 return f'<a:prstGeom prst="{prst}"><a:avLst>{guide_xml}</a:avLst></a:prstGeom>'
946
947
948 def _build_preserved_custom_geom(elem: ET.Element) -> str | None:
949 """Return unchanged native ``a:custGeom`` metadata, or mark it stale."""
950 kind = elem.get('data-pptx-geometry-kind')
951 if kind is None:
952 return None
953 if kind != 'custom':
954 raise ValueError(f'Unsupported data-pptx-geometry-kind {kind!r}')
955 encoded = elem.get('data-pptx-custgeom')
956 expected_hash = elem.get('data-pptx-geometry-sha256')
957 if not encoded or not expected_hash:
958 raise ValueError(
959 'Custom geometry metadata requires data-pptx-custgeom and '
960 'data-pptx-geometry-sha256'
961 )
962 actual_hash = hashlib.sha256(
963 (elem.get('d') or '').strip().encode('utf-8')
964 ).hexdigest()
965 if actual_hash != expected_hash:
966 return None
967 try:
968 raw = base64.b64decode(encoded, validate=True)
969 custom = ET.fromstring(raw)
970 decoded = raw.decode('utf-8')
971 except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
972 raise ValueError(f'Invalid custom geometry metadata: {exc}') from exc
973 if custom.tag != (
974 '{http://schemas.openxmlformats.org/drawingml/2006/main}custGeom'
975 ):
976 raise ValueError('Custom geometry metadata payload must be a:custGeom')
977 if has_relationship_attributes(custom):
978 raise ValueError(
979 'Custom geometry metadata must not contain relationship attributes'
980 )
981 return decoded
982
983
984 def _shape_xfrm_from_preset_frame(
985 elem: ET.Element,
986 ctx: ConvertContext,
987 fallback_raw_rect: tuple[float, float, float, float],
988 fallback_resolved_rect: tuple[float, float, float, float],
989 transform: str | None,
990 ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
991 """Use the preserved logical frame for native preset size when present."""
992 prst, _guides, frame = _parse_preset_geometry_metadata(elem)
993 if frame is None:
994 raw_x, raw_y, raw_w, raw_h = fallback_raw_rect
995 x, y, w, h = fallback_resolved_rect
996 else:
997 raw_x, raw_y, raw_w, raw_h = frame
998 x = ctx_x(raw_x, ctx)
999 y = ctx_y(raw_y, ctx)
1000 w = ctx_w(raw_w, ctx)
1001 h = ctx_h(raw_h, ctx)
1002 preserves_zero_axis = (
1003 elem.get('data-pptx-object') == 'connector'
1004 or prst in CONNECTOR_PRESET_TYPES
1005 )
1006 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect(
1007 ctx,
1008 raw_x,
1009 raw_y,
1010 raw_w,
1011 raw_h,
1012 x,
1013 y,
1014 w,
1015 h,
1016 transform,
1017 preserve_degenerate_axes=preserves_zero_axis,
1018 )
1019 if not preserves_zero_axis:
1020 ext_cx = max(ext_cx, 1)
1021 ext_cy = max(ext_cy, 1)
1022 bounds_emu = (
1023 bounds_emu[0],
1024 bounds_emu[1],
1025 max(bounds_emu[2], off_x + ext_cx),
1026 max(bounds_emu[3], off_y + ext_cy),
1027 )
1028 return xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu
1029
1030
1031 def _pathlike_preset_xfrm(
1032 elem: ET.Element,
1033 ctx: ConvertContext,
1034 transform: str | None,
1035 min_x: float,
1036 min_y: float,
1037 width: float,
1038 height: float,
1039 ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
1040 """Resolve a path-like preset xfrm from its logical frame or visual bounds."""
1041 _prst, _guides, frame = _parse_preset_geometry_metadata(elem)
1042 if frame is None:
1043 if _uses_full_transform(ctx, transform):
1044 tag = elem.tag.rsplit('}', 1)[-1]
1045 raise ValueError(
1046 f'Transformed preset-bearing <{tag}> requires data-pptx-frame '
1047 'to preserve its logical size'
1048 )
1049 off_x = px_to_emu(min_x)
1050 off_y = px_to_emu(min_y)
1051 ext_cx = max(px_to_emu(width), 1)
1052 ext_cy = max(px_to_emu(height), 1)
1053 return (
1054 '',
1055 off_x,
1056 off_y,
1057 ext_cx,
1058 ext_cy,
1059 (off_x, off_y, off_x + ext_cx, off_y + ext_cy),
1060 )
1061 return _shape_xfrm_from_preset_frame(
1062 elem,
1063 ctx,
1064 (0.0, 0.0, 1.0, 1.0),
1065 (min_x, min_y, width, height),
1066 transform,
1067 )
1068
1069
1070 def _build_round_rect_custgeom(w: float, h: float, rx: float, ry: float) -> str:
1071 """Build a DrawingML ``custGeom`` for a rectangle with elliptical corners.
1072
1073 Used when ``<rect>`` has rx ≠ ry, which DrawingML's preset ``roundRect``
1074 cannot express (the preset takes a single ``adj`` shared by all four
1075 corners and is implicitly symmetric). Each 90° elliptical arc is
1076 approximated by one cubic Bézier — within 0.03% of the true ellipse, far
1077 below any visible threshold at slide resolution.
1078
1079 Trade-off vs. the symmetric ``prstGeom roundRect`` path: this geometry
1080 is custom, so PowerPoint's yellow corner-radius handle is gone and the
1081 shape can no longer be retuned in-place. That matches the underlying
1082 reality — rx ≠ ry has no single "radius" to drag — and remains far
1083 better than the previous behaviour (silently dropping all corners and
1084 rendering a hard rectangle).
1085
1086 Args:
1087 w, h: Pixel dimensions of the rectangle (post ctx-scale).
1088 rx, ry: Pixel corner radii along x and y. Will be clamped to half
1089 of w / h respectively per the SVG spec.
1090
1091 Returns:
1092 A complete ``<a:custGeom>...</a:custGeom>`` XML string. Coordinates
1093 are emitted in EMU within a path-local coordinate system whose
1094 ``w`` / ``h`` equal the rectangle's pixel-converted dimensions.
1095 """
1096 # Clamp radii (SVG spec): rx > w/2 collapses to a half-circle end.
1097 rx = min(max(rx, 0.0), w / 2)
1098 ry = min(max(ry, 0.0), h / 2)
1099
1100 width_emu = px_to_emu(w)
1101 height_emu = px_to_emu(h)
1102 rx_emu = px_to_emu(rx)
1103 ry_emu = px_to_emu(ry)
1104
1105 cx_off = int(round(rx_emu * _BEZIER_QUARTER_K))
1106 cy_off = int(round(ry_emu * _BEZIER_QUARTER_K))
1107
1108 def pt(x: int, y: int) -> str:
1109 return f'<a:pt x="{x}" y="{y}"/>'
1110
1111 def cubic(c1: tuple[int, int], c2: tuple[int, int], end: tuple[int, int]) -> str:
1112 return (
1113 f'<a:cubicBezTo>{pt(*c1)}{pt(*c2)}{pt(*end)}</a:cubicBezTo>'
1114 )
1115
1116 # Path traversed clockwise, starting just past the top-left corner.
1117 parts = [
1118 f'<a:moveTo>{pt(rx_emu, 0)}</a:moveTo>',
1119 f'<a:lnTo>{pt(width_emu - rx_emu, 0)}</a:lnTo>',
1120 # Top-right corner: (W-Rx, 0) → (W, Ry)
1121 cubic(
1122 (width_emu - rx_emu + cx_off, 0),
1123 (width_emu, ry_emu - cy_off),
1124 (width_emu, ry_emu),
1125 ),
1126 f'<a:lnTo>{pt(width_emu, height_emu - ry_emu)}</a:lnTo>',
1127 # Bottom-right corner: (W, H-Ry) → (W-Rx, H)
1128 cubic(
1129 (width_emu, height_emu - ry_emu + cy_off),
1130 (width_emu - rx_emu + cx_off, height_emu),
1131 (width_emu - rx_emu, height_emu),
1132 ),
1133 f'<a:lnTo>{pt(rx_emu, height_emu)}</a:lnTo>',
1134 # Bottom-left corner: (Rx, H) → (0, H-Ry)
1135 cubic(
1136 (rx_emu - cx_off, height_emu),
1137 (0, height_emu - ry_emu + cy_off),
1138 (0, height_emu - ry_emu),
1139 ),
1140 f'<a:lnTo>{pt(0, ry_emu)}</a:lnTo>',
1141 # Top-left corner: (0, Ry) → (Rx, 0)
1142 cubic(
1143 (0, ry_emu - cy_off),
1144 (rx_emu - cx_off, 0),
1145 (rx_emu, 0),
1146 ),
1147 '<a:close/>',
1148 ]
1149
1150 path_xml = '\n'.join(parts)
1151 return (
1152 '<a:custGeom>'
1153 '<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
1154 '<a:rect l="l" t="t" r="r" b="b"/>'
1155 f'<a:pathLst><a:path w="{width_emu}" h="{height_emu}">'
1156 f'\n{path_xml}\n'
1157 '</a:path></a:pathLst>'
1158 '</a:custGeom>'
1159 )
1160
1161
1162 def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1163 """Convert SVG <rect> to DrawingML shape.
1164
1165 Symmetric rounded corners (rx == ry) are emitted as ``prstGeom roundRect``
1166 so PowerPoint treats them as a native rounded-rectangle shape: the yellow
1167 adjustment handle stays draggable, and "Reset Picture / Shape" works as
1168 expected. Elliptical corners (rx != ry) fall back to plain rect geometry
1169 for now — current corpora contain none, but the branch keeps callers from
1170 silently producing distorted custom geometry if one ever appears.
1171 """
1172 raw_x = svg_length_x(elem.get('x'), ctx)
1173 raw_y = svg_length_y(elem.get('y'), ctx)
1174 raw_w = svg_length_x(elem.get('width'), ctx)
1175 raw_h = svg_length_y(elem.get('height'), ctx)
1176 x = ctx_x(raw_x, ctx)
1177 y = ctx_y(raw_y, ctx)
1178 w = ctx_w(raw_w, ctx)
1179 h = ctx_h(raw_h, ctx)
1180 preset_geom = _build_preset_geom_from_meta(elem)
1181
1182 if w <= 0 or h <= 0:
1183 return None
1184
1185 # SVG spec: when only one of rx/ry is specified, the other inherits its
1186 # value. Real-world svg_output decks always write only `rx`, so ry must
1187 # be inferred to keep round corners from collapsing to zero on one axis.
1188 rx_attr = elem.get('rx')
1189 ry_attr = elem.get('ry')
1190 rx_raw = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
1191 ry_raw = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
1192 if rx_attr is not None and ry_attr is None:
1193 ry_raw = rx_raw
1194 elif ry_attr is not None and rx_attr is None:
1195 rx_raw = ry_raw
1196 rx = rx_raw * ctx.scale_x
1197 ry = ry_raw * ctx.scale_y
1198
1199 fill_op = get_fill_opacity(elem, ctx)
1200 stroke_op = get_stroke_opacity(elem, ctx)
1201 fill = build_fill_xml(elem, ctx, fill_op)
1202 stroke = build_stroke_xml(elem, ctx, stroke_op)
1203
1204 effect = ''
1205 filt_id = get_effective_filter_id(elem, ctx)
1206 if filt_id and filt_id in ctx.defs:
1207 effect = build_effect_xml(
1208 ctx.defs[filt_id],
1209 get_element_opacity(elem, ctx),
1210 )
1211
1212 transform = elem.get('transform')
1213
1214 if preset_geom is not None:
1215 geom = preset_geom
1216 elif rx > 0 and abs(rx - ry) < 0.5:
1217 # Symmetric corners → native PowerPoint rounded rectangle. adj is
1218 # the corner radius as a fraction of the shorter side, in 1/1000-
1219 # percent units, capped at 50000 (= radius equals half the shorter
1220 # side, i.e. capsule end).
1221 short_side = min(w, h)
1222 radius = min(rx, short_side / 2)
1223 adj = max(0, min(50000, int(round(radius / short_side * 100000))))
1224 geom = (
1225 '<a:prstGeom prst="roundRect">'
1226 f'<a:avLst><a:gd name="adj" fmla="val {adj}"/></a:avLst>'
1227 '</a:prstGeom>'
1228 )
1229 elif rx > 0 or ry > 0:
1230 # Asymmetric corners (rx != ry) → DrawingML has no preset for
1231 # elliptical-corner rectangles, so emit a custGeom with one cubic
1232 # Bézier per 90° arc. We lose the prstGeom roundRect adjustment
1233 # handle, but symmetric and asymmetric cases now both render with
1234 # rounded corners instead of one of them silently flattening to
1235 # a hard rectangle.
1236 geom = _build_round_rect_custgeom(w, h, rx, ry)
1237 else:
1238 geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
1239
1240 shape_id = _claim_element_shape_id(elem, ctx)
1241 if preset_geom is not None:
1242 xfrm = _shape_xfrm_from_preset_frame(
1243 elem,
1244 ctx,
1245 (raw_x, raw_y, raw_w, raw_h),
1246 (x, y, w, h),
1247 transform,
1248 )
1249 else:
1250 xfrm = _shape_xfrm_from_svg_rect(
1251 ctx,
1252 raw_x,
1253 raw_y,
1254 raw_w,
1255 raw_h,
1256 x,
1257 y,
1258 w,
1259 h,
1260 transform,
1261 )
1262 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm
1263 return ShapeResult(
1264 xml=_wrap_geometry_object(
1265 elem,
1266 ctx,
1267 shape_id, f'Rectangle {shape_id}',
1268 off_x, off_y, ext_cx, ext_cy,
1269 geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
1270 ),
1271 bounds_emu=bounds_emu,
1272 )
1273
1274
1275 # ---------------------------------------------------------------------------
1276 # circle (including donut-chart arc segments)
1277 # ---------------------------------------------------------------------------
1278
1279 def _build_arc_ring_path(
1280 cx: float, cy: float, r: float,
1281 stroke_width: float,
1282 dash_len: float, dash_offset: float,
1283 rotate_deg: float,
1284 sx: float, sy: float,
1285 ) -> tuple[str, int, int, int, int]:
1286 """Build a filled annular-sector (donut segment) as DrawingML custGeom.
1287
1288 SVG donut charts use stroke-dasharray on a circle to draw arc segments.
1289 DrawingML cannot reproduce this, so we convert each arc segment into a
1290 filled ring shape (outer arc -> line -> inner arc -> close).
1291
1292 Returns:
1293 (geom_xml, min_x_emu, min_y_emu, w_emu, h_emu).
1294 """
1295 circumference = 2 * math.pi * r
1296 if circumference <= 0:
1297 return '', 0, 0, 0, 0
1298
1299 start_frac = -dash_offset / circumference
1300 end_frac = start_frac + dash_len / circumference
1301
1302 start_angle = start_frac * 2 * math.pi + math.radians(rotate_deg)
1303 end_angle = end_frac * 2 * math.pi + math.radians(rotate_deg)
1304
1305 half_sw = stroke_width / 2
1306 r_outer = r + half_sw
1307 r_inner = r - half_sw
1308
1309 num_segments = max(16, int(abs(end_angle - start_angle) / (math.pi / 32)))
1310 angles = [
1311 start_angle + (end_angle - start_angle) * i / num_segments
1312 for i in range(num_segments + 1)
1313 ]
1314
1315 outer_pts = [(cx + r_outer * math.sin(a), cy - r_outer * math.cos(a)) for a in angles]
1316 inner_pts = [(cx + r_inner * math.sin(a), cy - r_inner * math.cos(a)) for a in reversed(angles)]
1317
1318 all_pts = [(px * sx, py * sy) for px, py in outer_pts + inner_pts]
1319
1320 xs = [p[0] for p in all_pts]
1321 ys = [p[1] for p in all_pts]
1322 min_x, max_x = min(xs), max(xs)
1323 min_y, max_y = min(ys), max(ys)
1324 width = max_x - min_x
1325 height = max_y - min_y
1326
1327 if width < 0.5 or height < 0.5:
1328 return '', 0, 0, 0, 0
1329
1330 w_emu = px_to_emu(width)
1331 h_emu = px_to_emu(height)
1332
1333 lines: list[str] = []
1334 for i, (px, py) in enumerate(all_pts):
1335 lx = px_to_emu(px - min_x)
1336 ly = px_to_emu(py - min_y)
1337 if i == 0:
1338 lines.append(f'<a:moveTo><a:pt x="{lx}" y="{ly}"/></a:moveTo>')
1339 else:
1340 lines.append(f'<a:lnTo><a:pt x="{lx}" y="{ly}"/></a:lnTo>')
1341 lines.append('<a:close/>')
1342
1343 path_xml = '\n'.join(lines)
1344 geom = f'''<a:custGeom>
1345 <a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
1346 <a:rect l="l" t="t" r="r" b="b"/>
1347 <a:pathLst><a:path w="{w_emu}" h="{h_emu}">
1348 {path_xml}
1349 </a:path></a:pathLst>
1350 </a:custGeom>'''
1351
1352 return geom, px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu
1353
1354
1355 def _is_donut_circle(elem: ET.Element, ctx: ConvertContext) -> bool:
1356 """Detect if a circle uses stroke-dasharray to simulate an arc segment."""
1357 dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
1358 stroke = _get_attr(elem, 'stroke', ctx)
1359 fill = _get_attr(elem, 'fill', ctx)
1360 sw = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 0)
1361 r = svg_length_size(elem.get('r'), ctx, 0)
1362 return is_thick_circle_shorthand(dasharray, stroke, fill, sw, r)
1363
1364
1365 def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1366 """Convert SVG <circle> to DrawingML ellipse or donut-arc shape."""
1367 cx_ = svg_length_x(elem.get('cx'), ctx)
1368 cy_ = svg_length_y(elem.get('cy'), ctx)
1369 r = svg_length_size(elem.get('r'), ctx)
1370 preset_geom = _build_preset_geom_from_meta(elem)
1371
1372 if r <= 0:
1373 return None
1374
1375 # --- Donut-chart arc segment detection ---
1376 if preset_geom is None and _is_donut_circle(elem, ctx):
1377 dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
1378 parsed_dasharray = parse_project_stroke_dasharray(
1379 dasharray,
1380 allow_zero_gap=True,
1381 )
1382 if parsed_dasharray is None:
1383 raise ValueError('Thick-circle arc requires one dash/gap pair')
1384 _preset, dash_values = parsed_dasharray
1385 dash_len = dash_values[0]
1386 raw_dash_offset = elem.get('stroke-dashoffset')
1387 dash_offset = (
1388 parse_project_geometry_length(
1389 raw_dash_offset,
1390 'stroke-dashoffset',
1391 )
1392 if raw_dash_offset is not None else 0.0
1393 )
1394 stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1)
1395
1396 rotate_deg = 0.0
1397 transform = elem.get('transform', '')
1398 if transform:
1399 operations = parse_transform_operations(transform)
1400 if len(operations) != 1 or operations[0][0] != 'rotate':
1401 raise ValueError(
1402 'Thick-circle arc transform must be one rotate operation'
1403 )
1404 rotate_deg = operations[0][1][0]
1405
1406 geom, min_x, min_y, w_emu, h_emu = _build_arc_ring_path(
1407 ctx_x(cx_, ctx) / ctx.scale_x,
1408 ctx_y(cy_, ctx) / ctx.scale_y,
1409 r, stroke_width, dash_len, dash_offset, rotate_deg,
1410 ctx.scale_x, ctx.scale_y,
1411 )
1412 if not geom:
1413 return None
1414
1415 # Use the stroke color/gradient as fill for the arc shape
1416 stroke_val = _get_attr(elem, 'stroke', ctx)
1417 op = get_stroke_opacity(elem, ctx)
1418 grad_id = resolve_url_id(stroke_val) if stroke_val else None
1419 if grad_id and grad_id in ctx.defs:
1420 fill = build_gradient_fill(
1421 ctx.defs[grad_id],
1422 op,
1423 ctx.theme_color_spec,
1424 "fill",
1425 )
1426 elif stroke_val:
1427 color, color_alpha = parse_svg_color(stroke_val)
1428 fill = (
1429 build_solid_fill(
1430 color,
1431 combine_opacity(op, color_alpha),
1432 ctx.theme_color_spec,
1433 "fill",
1434 )
1435 if color else '<a:noFill/>'
1436 )
1437 else:
1438 fill = '<a:noFill/>'
1439
1440 stroke_xml = '<a:ln><a:noFill/></a:ln>'
1441
1442 effect = ''
1443 filt_id = get_effective_filter_id(elem, ctx)
1444 if filt_id and filt_id in ctx.defs:
1445 effect = build_effect_xml(
1446 ctx.defs[filt_id],
1447 get_element_opacity(elem, ctx),
1448 )
1449
1450 shape_id = _claim_element_shape_id(elem, ctx)
1451 return ShapeResult(
1452 xml=_wrap_shape(
1453 shape_id, f'Arc {shape_id}',
1454 min_x, min_y, w_emu, h_emu,
1455 geom, fill, stroke_xml, effect,
1456 ),
1457 bounds_emu=(min_x, min_y, min_x + w_emu, min_y + h_emu),
1458 )
1459
1460 # --- Normal circle ---
1461 transform = elem.get('transform')
1462 cx_s = ctx_x(cx_, ctx)
1463 cy_s = ctx_y(cy_, ctx)
1464 r_x = r * ctx.scale_x
1465 r_y = r * ctx.scale_y
1466
1467 x = cx_s - r_x
1468 y = cy_s - r_y
1469 w = r_x * 2
1470 h = r_y * 2
1471
1472 fill_op = get_fill_opacity(elem, ctx)
1473 stroke_op = get_stroke_opacity(elem, ctx)
1474 fill = build_fill_xml(elem, ctx, fill_op)
1475 stroke = build_stroke_xml(elem, ctx, stroke_op)
1476
1477 effect = ''
1478 filt_id = get_effective_filter_id(elem, ctx)
1479 if filt_id and filt_id in ctx.defs:
1480 effect = build_effect_xml(
1481 ctx.defs[filt_id],
1482 get_element_opacity(elem, ctx),
1483 )
1484
1485 geom = preset_geom or '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
1486
1487 shape_id = _claim_element_shape_id(elem, ctx)
1488 if preset_geom is not None:
1489 xfrm = _shape_xfrm_from_preset_frame(
1490 elem,
1491 ctx,
1492 (cx_ - r, cy_ - r, r * 2, r * 2),
1493 (x, y, w, h),
1494 transform,
1495 )
1496 else:
1497 xfrm = _shape_xfrm_from_svg_rect(
1498 ctx,
1499 cx_ - r,
1500 cy_ - r,
1501 r * 2,
1502 r * 2,
1503 x,
1504 y,
1505 w,
1506 h,
1507 transform,
1508 )
1509 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm
1510 return ShapeResult(
1511 xml=_wrap_geometry_object(
1512 elem,
1513 ctx,
1514 shape_id, f'Ellipse {shape_id}',
1515 off_x, off_y, ext_cx, ext_cy,
1516 geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
1517 ),
1518 bounds_emu=bounds_emu,
1519 )
1520
1521
1522 # ---------------------------------------------------------------------------
1523 # line
1524 # ---------------------------------------------------------------------------
1525
1526 def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1527 """Convert SVG <line> to DrawingML shape.
1528
1529 Lines with marker-start / marker-end are converted using the 'line' preset
1530 geometry (prstGeom prst="line") so that PowerPoint renders native arrow
1531 heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to
1532 use custom geometry which is sufficient and avoids flipH/flipV complexity.
1533 """
1534 preset_geom = _build_preset_geom_from_meta(elem)
1535 transform = elem.get('transform')
1536 raw_x1 = svg_length_x(elem.get('x1'), ctx)
1537 raw_y1 = svg_length_y(elem.get('y1'), ctx)
1538 raw_x2 = svg_length_x(elem.get('x2'), ctx)
1539 raw_y2 = svg_length_y(elem.get('y2'), ctx)
1540 x1, y1 = _transformed_point(
1541 ctx,
1542 raw_x1,
1543 raw_y1,
1544 transform,
1545 )
1546 x2, y2 = _transformed_point(
1547 ctx,
1548 raw_x2,
1549 raw_y2,
1550 transform,
1551 )
1552
1553 min_x = min(x1, x2)
1554 min_y = min(y1, y2)
1555
1556 stroke_op = get_stroke_opacity(elem, ctx)
1557 stroke = build_stroke_xml(elem, ctx, stroke_op)
1558
1559 shape_id = _claim_element_shape_id(elem, ctx)
1560 off_x = px_to_emu(min_x)
1561 off_y = px_to_emu(min_y)
1562
1563 # Determine if this line carries arrow markers.
1564 has_marker = bool(
1565 _get_attr(elem, 'marker-start', ctx) or
1566 _get_attr(elem, 'marker-end', ctx)
1567 )
1568
1569 if preset_geom is not None:
1570 # The preserved logical frame, not the rendered stroke/marker bounds,
1571 # owns the native shape size. Horizontal/vertical connectors retain a
1572 # one-EMU extent on the degenerate axis as required by DrawingML.
1573 raw_w = abs(raw_x2 - raw_x1)
1574 raw_h = abs(raw_y2 - raw_y1)
1575 resolved_w = abs(x2 - x1)
1576 resolved_h = abs(y2 - y1)
1577 xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = (
1578 _shape_xfrm_from_preset_frame(
1579 elem,
1580 ctx,
1581 (min(raw_x1, raw_x2), min(raw_y1, raw_y2), raw_w, raw_h),
1582 (min_x, min_y, resolved_w, resolved_h),
1583 transform,
1584 )
1585 )
1586 if not _uses_full_transform(ctx, transform):
1587 flip_attrs = []
1588 if x1 > x2:
1589 flip_attrs.append(' flipH="1"')
1590 if y1 > y2:
1591 flip_attrs.append(' flipV="1"')
1592 xfrm_attr += ''.join(flip_attrs)
1593 xml = _wrap_geometry_object(
1594 elem,
1595 ctx,
1596 shape_id,
1597 f'Connector {shape_id}' if elem.get('data-pptx-object') == 'connector'
1598 else f'Line {shape_id}',
1599 off_x,
1600 off_y,
1601 w_emu,
1602 h_emu,
1603 preset_geom,
1604 '<a:noFill/>',
1605 stroke,
1606 xfrm_attr=xfrm_attr,
1607 )
1608 return ShapeResult(xml=xml, bounds_emu=bounds_emu)
1609
1610 if has_marker:
1611 # ----------------------------------------------------------------
1612 # Preset geometry approach: prstGeom prst="line"
1613 # PowerPoint only renders headEnd / tailEnd on lines whose geometry
1614 # it can intrinsically understand as a "line" (i.e. preset or
1615 # connector shapes). Custom geometry shapes silently ignore
1616 # headEnd / tailEnd in most PowerPoint versions.
1617 #
1618 # The "line" preset draws from (0,0) to (w,h).
1619 # headEnd → placed at the start of the line = (x1, y1)
1620 # tailEnd → placed at the end of the line = (x2, y2)
1621 # We set flipH / flipV so that the preset start/end align with the
1622 # original SVG endpoints:
1623 # default (no flip) : top-left → bottom-right (x1≤x2, y1≤y2)
1624 # flipH : top-right → bottom-left (x1>x2, y1≤y2)
1625 # flipV : bottom-left → top-right (x1≤x2, y1>y2)
1626 # flipH + flipV : bottom-right → top-left (x1>x2, y1>y2)
1627 # ----------------------------------------------------------------
1628 w = abs(x2 - x1)
1629 h = abs(y2 - y1)
1630 # DrawingML requires ext cx/cy ≥ 1 EMU
1631 w_emu = px_to_emu(w) if w > 0 else 1
1632 h_emu = px_to_emu(h) if h > 0 else 1
1633
1634 flip_h = x1 > x2
1635 flip_v = y1 > y2
1636 flip_attr = ''
1637 if flip_h and flip_v:
1638 flip_attr = ' flipH="1" flipV="1"'
1639 elif flip_h:
1640 flip_attr = ' flipH="1"'
1641 elif flip_v:
1642 flip_attr = ' flipV="1"'
1643
1644 xml = _wrap_shape(
1645 shape_id,
1646 f'Line {shape_id}',
1647 off_x,
1648 off_y,
1649 w_emu,
1650 h_emu,
1651 '<a:prstGeom prst="line"><a:avLst/></a:prstGeom>',
1652 '<a:noFill/>',
1653 stroke,
1654 xfrm_attr=flip_attr,
1655 )
1656 else:
1657 # ----------------------------------------------------------------
1658 # Custom geometry (original behaviour) for plain lines.
1659 # ----------------------------------------------------------------
1660 w = max(abs(x2 - x1), 1)
1661 h = max(abs(y2 - y1), 1)
1662 w_emu = px_to_emu(w)
1663 h_emu = px_to_emu(h)
1664
1665 lx1 = px_to_emu(x1 - min_x)
1666 ly1 = px_to_emu(y1 - min_y)
1667 lx2 = px_to_emu(x2 - min_x)
1668 ly2 = px_to_emu(y2 - min_y)
1669
1670 geom = (
1671 f'<a:custGeom>'
1672 f'<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
1673 f'<a:rect l="l" t="t" r="r" b="b"/>'
1674 f'<a:pathLst><a:path w="{w_emu}" h="{h_emu}">'
1675 f'<a:moveTo><a:pt x="{lx1}" y="{ly1}"/></a:moveTo>'
1676 f'<a:lnTo><a:pt x="{lx2}" y="{ly2}"/></a:lnTo>'
1677 f'</a:path></a:pathLst>'
1678 f'</a:custGeom>'
1679 )
1680 xml = _wrap_shape(
1681 shape_id, f'Line {shape_id}',
1682 off_x, off_y, w_emu, h_emu,
1683 geom, '<a:noFill/>', stroke,
1684 )
1685
1686 return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu))
1687
1688
1689 # ---------------------------------------------------------------------------
1690 # path
1691 # ---------------------------------------------------------------------------
1692
1693 def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1694 """Convert SVG <path> to DrawingML custom geometry shape."""
1695 preset_geom = _build_preset_geom_from_meta(elem)
1696 preserved_custom_geom = _build_preserved_custom_geom(elem)
1697 native_geom = preset_geom or preserved_custom_geom
1698 d = elem.get('d', '')
1699 if not d:
1700 if native_geom is not None:
1701 raise ValueError('Native-geometry <path> requires a non-empty d attribute')
1702 return None
1703
1704 commands = parse_svg_path(d)
1705 commands = svg_path_to_absolute(commands)
1706 commands = normalize_path_commands(commands)
1707
1708 transform = elem.get('transform')
1709 if _uses_full_transform(ctx, transform):
1710 commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
1711 path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
1712 commands, 0, 0, 1.0, 1.0,
1713 )
1714 else:
1715 path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
1716 commands, ctx.translate_x, ctx.translate_y,
1717 ctx.scale_x, ctx.scale_y,
1718 )
1719
1720 if not path_xml:
1721 return None
1722
1723 w_emu = px_to_emu(width)
1724 h_emu = px_to_emu(height)
1725
1726 geom = native_geom
1727 if geom is None:
1728 geom = f'''<a:custGeom>
1729 <a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
1730 <a:rect l="l" t="t" r="r" b="b"/>
1731 <a:pathLst><a:path w="{w_emu}" h="{h_emu}">
1732 {path_xml}
1733 </a:path></a:pathLst>
1734 </a:custGeom>'''
1735
1736 fill_op = get_fill_opacity(elem, ctx)
1737 stroke_op = get_stroke_opacity(elem, ctx)
1738 fill = build_fill_xml(elem, ctx, fill_op)
1739 stroke = build_stroke_xml(elem, ctx, stroke_op)
1740
1741 effect = ''
1742 filt_id = get_effective_filter_id(elem, ctx)
1743 if filt_id and filt_id in ctx.defs:
1744 effect = build_effect_xml(
1745 ctx.defs[filt_id],
1746 get_element_opacity(elem, ctx),
1747 )
1748
1749 shape_id = _claim_element_shape_id(elem, ctx)
1750 xfrm_attr = ''
1751 off_x = px_to_emu(min_x)
1752 off_y = px_to_emu(min_y)
1753 bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu)
1754 if native_geom is not None:
1755 xfrm = _pathlike_preset_xfrm(
1756 elem,
1757 ctx,
1758 transform,
1759 min_x,
1760 min_y,
1761 width,
1762 height,
1763 )
1764 xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm
1765 return ShapeResult(
1766 xml=_wrap_geometry_object(
1767 elem,
1768 ctx,
1769 shape_id, f'Freeform {shape_id}',
1770 off_x, off_y, w_emu, h_emu,
1771 geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
1772 ),
1773 bounds_emu=bounds_emu,
1774 )
1775
1776
1777 # ---------------------------------------------------------------------------
1778 # polygon / polyline
1779 # ---------------------------------------------------------------------------
1780
1781 def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1782 """Convert SVG <polygon> to DrawingML custom geometry shape."""
1783 preset_geom = _build_preset_geom_from_meta(elem)
1784 points = parse_svg_points(elem.get('points', ''), min_points=3)
1785
1786 commands = [PathCommand('M', [points[0][0], points[0][1]])]
1787 for px_, py_ in points[1:]:
1788 commands.append(PathCommand('L', [px_, py_]))
1789 commands.append(PathCommand('Z', []))
1790
1791 transform = elem.get('transform')
1792 if _uses_full_transform(ctx, transform):
1793 commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
1794 path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
1795 commands, 0, 0, 1.0, 1.0,
1796 )
1797 else:
1798 path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
1799 commands, ctx.translate_x, ctx.translate_y,
1800 ctx.scale_x, ctx.scale_y,
1801 )
1802
1803 if not path_xml:
1804 return None
1805
1806 w_emu = px_to_emu(width)
1807 h_emu = px_to_emu(height)
1808
1809 geom = preset_geom or f'''<a:custGeom>
1810 <a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
1811 <a:rect l="l" t="t" r="r" b="b"/>
1812 <a:pathLst><a:path w="{w_emu}" h="{h_emu}">
1813 {path_xml}
1814 </a:path></a:pathLst>
1815 </a:custGeom>'''
1816
1817 fill_op = get_fill_opacity(elem, ctx)
1818 stroke_op = get_stroke_opacity(elem, ctx)
1819 fill = build_fill_xml(elem, ctx, fill_op)
1820 stroke = build_stroke_xml(elem, ctx, stroke_op)
1821
1822 shape_id = _claim_element_shape_id(elem, ctx)
1823 xfrm_attr = ''
1824 off_x = px_to_emu(min_x)
1825 off_y = px_to_emu(min_y)
1826 bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu)
1827 if preset_geom is not None:
1828 xfrm = _pathlike_preset_xfrm(
1829 elem,
1830 ctx,
1831 transform,
1832 min_x,
1833 min_y,
1834 width,
1835 height,
1836 )
1837 xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm
1838 return ShapeResult(
1839 xml=_wrap_geometry_object(
1840 elem,
1841 ctx,
1842 shape_id, f'Polygon {shape_id}',
1843 off_x, off_y, w_emu, h_emu,
1844 geom, fill, stroke, xfrm_attr=xfrm_attr,
1845 ),
1846 bounds_emu=bounds_emu,
1847 )
1848
1849
1850 def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1851 """Convert SVG <polyline> to DrawingML custom geometry shape."""
1852 preset_geom = _build_preset_geom_from_meta(elem)
1853 points = parse_svg_points(elem.get('points', ''), min_points=2)
1854
1855 commands = [PathCommand('M', [points[0][0], points[0][1]])]
1856 for px_, py_ in points[1:]:
1857 commands.append(PathCommand('L', [px_, py_]))
1858
1859 transform = elem.get('transform')
1860 if _uses_full_transform(ctx, transform):
1861 commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
1862 path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
1863 commands, 0, 0, 1.0, 1.0,
1864 )
1865 else:
1866 path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
1867 commands, ctx.translate_x, ctx.translate_y,
1868 ctx.scale_x, ctx.scale_y,
1869 )
1870
1871 if not path_xml:
1872 return None
1873
1874 w_emu = px_to_emu(width)
1875 h_emu = px_to_emu(height)
1876
1877 geom = preset_geom or f'''<a:custGeom>
1878 <a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
1879 <a:rect l="l" t="t" r="r" b="b"/>
1880 <a:pathLst><a:path w="{w_emu}" h="{h_emu}">
1881 {path_xml}
1882 </a:path></a:pathLst>
1883 </a:custGeom>'''
1884
1885 fill_op = get_fill_opacity(elem, ctx)
1886 stroke_op = get_stroke_opacity(elem, ctx)
1887 fill = build_fill_xml(elem, ctx, fill_op)
1888 stroke = build_stroke_xml(elem, ctx, stroke_op)
1889
1890 shape_id = _claim_element_shape_id(elem, ctx)
1891 xfrm_attr = ''
1892 off_x = px_to_emu(min_x)
1893 off_y = px_to_emu(min_y)
1894 bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu)
1895 if preset_geom is not None:
1896 xfrm = _pathlike_preset_xfrm(
1897 elem,
1898 ctx,
1899 transform,
1900 min_x,
1901 min_y,
1902 width,
1903 height,
1904 )
1905 xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm
1906 return ShapeResult(
1907 xml=_wrap_geometry_object(
1908 elem,
1909 ctx,
1910 shape_id, f'Polyline {shape_id}',
1911 off_x, off_y, w_emu, h_emu,
1912 geom, '<a:noFill/>', stroke, xfrm_attr=xfrm_attr,
1913 ),
1914 bounds_emu=bounds_emu,
1915 )
1916
1917
1918 # ---------------------------------------------------------------------------
1919 # text
1920 # ---------------------------------------------------------------------------
1921
1922 _SERIF_WIDTH_FAMILIES = {
1923 'book antiqua',
1924 'cambria',
1925 'fangsong',
1926 'garamond',
1927 'georgia',
1928 'kaiti',
1929 'palatino',
1930 'palatino linotype',
1931 'serif',
1932 'simsun',
1933 'songti',
1934 'times',
1935 'times new roman',
1936 }
1937
1938 _TEXTBOX_PADDING_MIN_PX = 0.5
1939 _TEXTBOX_PADDING_MAX_PX = 2.0
1940 _TEXTBOX_PADDING_RATIO = 0.04
1941 # Single-line auto-fit headroom interpolates between a low-caps base and an
1942 # all-caps ceiling for each run. The crude per-char width estimate undercounts
1943 # capitals most, so all-caps runs need the ceiling to keep wrap-ignoring
1944 # renderers (LibreOffice) from folding. Applying headroom per run also prevents
1945 # a short serif label from forcing a conservative serif multiplier onto an
1946 # otherwise sans-serif line. Values are calibrated against LibreOffice renders
1947 # of all-caps bold lines, with bases left above mixed-case and CJK render
1948 # ratios; exact ratios shift with font substitution, so these carry deliberate
1949 # margin rather than tracking one environment's numbers.
1950 _TEXT_WIDTH_HEADROOM_BASE = 1.06
1951 _TEXT_WIDTH_HEADROOM_CAPS = 1.12
1952 _SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12
1953 _SERIF_TEXT_WIDTH_HEADROOM_CAPS = 1.36
1954 _TEXT_BULLET_MARKERS = {
1955 '·': '•',
1956 '•': '•',
1957 '●': '●',
1958 '▪': '▪',
1959 '■': '■',
1960 '◆': '◆',
1961 '◇': '◇',
1962 '◦': '◦',
1963 '‣': '‣',
1964 }
1965 _TEXT_BULLET_RE = re.compile(
1966 r'^(?P<prefix>\s*)(?P<marker>[·•●▪■◆◇◦‣])(?P<space>\s*)'
1967 )
1968 _INLINE_FORMULA_ATTR = 'data-pptx-inline-formula'
1969 _INLINE_FORMULA_KEY = '_inline_formula_latex'
1970
1971
1972 def _text_line_vertical_extent(
1973 runs: list[dict[str, Any]],
1974 font_size: float,
1975 ) -> tuple[float, float, bool]:
1976 """Return native-math-aware ascent/descent for one authored text line."""
1977 ascent = font_size * 0.85
1978 descent = font_size * 0.35
1979 has_inline_formula = False
1980 from ..native_objects.formula_compiler import (
1981 estimate_inline_formula_vertical_extent,
1982 )
1983
1984 for run in runs:
1985 latex = run.get(_INLINE_FORMULA_KEY)
1986 if latex is None:
1987 continue
1988 has_inline_formula = True
1989 run_font_size = float(run.get('font_size', font_size))
1990 extent = estimate_inline_formula_vertical_extent(str(latex))
1991 ascent = max(ascent, run_font_size * extent.ascent_em)
1992 descent = max(descent, run_font_size * extent.descent_em)
1993 return ascent, descent, has_inline_formula
1994
1995
1996 def _native_text_line_frame_height(
1997 ascent: float,
1998 descent: float,
1999 font_size: float,
2000 ) -> float:
2001 """Add the ordinary text-frame headroom to one visible line extent."""
2002 return ascent + descent + font_size * 0.30
2003
2004
2005 def _normalize_text_run_whitespace(
2006 runs: list[dict[str, Any]],
2007 ) -> list[dict[str, Any]]:
2008 """Apply the shared whitespace contract without losing run ownership."""
2009 normalized: list[dict[str, Any]] = []
2010 segments = [
2011 (str(run.get('_xml_space', 'default')), str(run.get('text', '')))
2012 for run in runs
2013 ]
2014 for index, text in normalize_project_text_segments(segments):
2015 run = {**runs[index], 'text': text}
2016 run.pop('_xml_space', None)
2017 normalized.append(run)
2018 return normalized
2019
2020
2021 def _letter_spacing_to_drawingml_spc(letter_spacing_px: float) -> str:
2022 """Convert SVG px letter spacing into DrawingML rPr@spc."""
2023 spacing = drawingml_letter_spacing(letter_spacing_px)
2024 if spacing == 0:
2025 return ''
2026 return f' spc="{spacing}"'
2027
2028
2029 def _is_serif_run(run: dict[str, Any]) -> bool:
2030 """Return whether a text run uses a serif-like family."""
2031 for family in str(run.get('font_family', '')).split(','):
2032 name = family.strip().strip("'\"").lower()
2033 if not name or name in {'sans-serif', 'sans serif'}:
2034 continue
2035 if name in _SERIF_WIDTH_FAMILIES:
2036 return True
2037 if 'serif' in name and 'sans' not in name:
2038 return True
2039 return False
2040
2041
2042 def _estimate_run_text_width(run: dict[str, Any]) -> float:
2043 """Estimate one run using the metrics actually emitted to DrawingML."""
2044 text = str(run.get('text', ''))
2045 font_size_px = (
2046 font_px_to_hpt(float(run.get('font_size', 16)))
2047 / FONT_PX_TO_HUNDREDTHS_PT
2048 )
2049 cluster_widths = estimate_text_cluster_widths(
2050 text,
2051 font_size_px,
2052 str(run.get('font_weight', '400')),
2053 )
2054 letter_spacing_px = (
2055 drawingml_letter_spacing(
2056 float(run.get('letter_spacing', 0.0) or 0.0)
2057 )
2058 / FONT_PX_TO_HUNDREDTHS_PT
2059 )
2060 return sum(cluster_widths) + letter_spacing_px * max(
2061 len(cluster_widths) - 1,
2062 0,
2063 )
2064
2065
2066 def validate_text_run_advances(runs: list[dict[str, Any]]) -> None:
2067 """Reject negative tracking that reverses or collapses one output run."""
2068 for run in runs:
2069 text = str(run.get('text', ''))
2070 letter_spacing = float(run.get('letter_spacing', 0.0) or 0.0)
2071 if len(split_project_text_clusters(text)) < 2 or letter_spacing >= 0:
2072 continue
2073 advance = _estimate_run_text_width(run)
2074 if advance > 0:
2075 continue
2076 snippet = re.sub(r'\s+', ' ', text)
2077 raise ValueError(
2078 'negative letter-spacing produces a non-positive DrawingML '
2079 f'text-run advance for {snippet!r} (advance={advance:g}px)'
2080 )
2081
2082
2083 def _uppercase_fraction(runs: list[dict[str, Any]]) -> float:
2084 """Fraction of cased letters across ``runs`` that are uppercase.
2085
2086 Caseless scripts (CJK, digits, punctuation) are ignored, so a Chinese or
2087 numeric line reports 0.0 and takes the low-caps headroom base.
2088 """
2089 upper = 0
2090 cased = 0
2091 for run in runs:
2092 for ch in str(run.get('text', '')):
2093 if ch.lower() != ch.upper():
2094 cased += 1
2095 if ch.isupper():
2096 upper += 1
2097 if not cased:
2098 return 0.0
2099 return upper / cased
2100
2101
2102 def _estimate_text_runs_width(
2103 runs: list[dict[str, Any]],
2104 *,
2105 include_headroom: bool = True,
2106 ) -> float:
2107 """Estimate a line of text runs.
2108
2109 ``include_headroom`` is useful for single-line auto-fit boxes where a
2110 renderer that measures text slightly wider would otherwise wrap. The
2111 headroom scales independently with each run's family and uppercase
2112 fraction. This keeps mixed-font lines from inheriting the most conservative
2113 run's multiplier. Paragraph boxes use this value as a wrapping constraint,
2114 so adding headroom there stretches the merged text frame beyond the
2115 author's source line width.
2116 """
2117 if not include_headroom:
2118 return sum(_estimate_run_text_width(run) for run in runs)
2119
2120 width = 0.0
2121 for run in runs:
2122 if _is_serif_run(run):
2123 base = _SERIF_TEXT_WIDTH_HEADROOM_BASE
2124 ceiling = _SERIF_TEXT_WIDTH_HEADROOM_CAPS
2125 else:
2126 base = _TEXT_WIDTH_HEADROOM_BASE
2127 ceiling = _TEXT_WIDTH_HEADROOM_CAPS
2128 caps = _uppercase_fraction([run])
2129 width += _estimate_run_text_width(run) * (
2130 base + (ceiling - base) * caps
2131 )
2132 return width
2133
2134
2135 def estimate_single_line_text_frame_width(
2136 runs: list[dict[str, Any]],
2137 *,
2138 include_headroom: bool = True,
2139 ) -> float:
2140 """Estimate one DrawingML textbox width with optional safety headroom."""
2141 content_runs, bullet = _extract_text_bullet(runs)
2142 width = _estimate_text_runs_width(
2143 content_runs,
2144 include_headroom=include_headroom,
2145 )
2146 if bullet:
2147 font_size = (
2148 float(content_runs[0].get('font_size', 16))
2149 if content_runs else 16.0
2150 )
2151 width += _bullet_margin_px(bullet, font_size)
2152 return width
2153
2154
2155 def validate_single_line_text_run_advances(
2156 runs: list[dict[str, Any]],
2157 ) -> None:
2158 """Validate the runs that remain after single-line bullet promotion."""
2159 content_runs, _bullet = _extract_text_bullet(runs)
2160 validate_text_run_advances(content_runs)
2161
2162
2163 def _first_nonspace_run(runs: list[dict[str, Any]]) -> dict[str, Any] | None:
2164 for run in runs:
2165 if str(run.get('text', '')).strip():
2166 return run
2167 return None
2168
2169
2170 def _strip_leading_chars_from_runs(
2171 runs: list[dict[str, Any]],
2172 char_count: int,
2173 ) -> list[dict[str, Any]]:
2174 stripped: list[dict[str, Any]] = []
2175 remaining = char_count
2176 for run in runs:
2177 if run.get('_line_break'):
2178 if remaining == 0:
2179 stripped.append(run)
2180 continue
2181 text = str(run.get('text', ''))
2182 if remaining >= len(text):
2183 remaining -= len(text)
2184 continue
2185 if remaining > 0:
2186 text = text[remaining:]
2187 remaining = 0
2188 if text:
2189 stripped.append({**run, 'text': text})
2190 return stripped
2191
2192
2193 def _take_leading_chars_from_runs(
2194 runs: list[dict[str, Any]],
2195 char_count: int,
2196 ) -> list[dict[str, Any]]:
2197 taken: list[dict[str, Any]] = []
2198 remaining = char_count
2199 for run in runs:
2200 if remaining <= 0:
2201 break
2202 text = str(run.get('text', ''))
2203 if remaining >= len(text):
2204 prefix = text
2205 remaining -= len(text)
2206 else:
2207 prefix = text[:remaining]
2208 remaining = 0
2209 if prefix:
2210 taken.append({**run, 'text': prefix})
2211 return taken
2212
2213
2214 def _extract_text_bullet(
2215 runs: list[dict[str, Any]],
2216 ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
2217 """Convert a leading text bullet marker into paragraph metadata."""
2218 first_nonspace = _first_nonspace_run(runs)
2219 if first_nonspace and (
2220 first_nonspace.get(_INLINE_FORMULA_KEY) is not None
2221 or first_nonspace.get(HYPERLINK_RID_KEY) is not None
2222 ):
2223 return runs, None
2224 full_text = ''.join(str(run.get('text', '')) for run in runs)
2225 match = _TEXT_BULLET_RE.match(full_text)
2226 if not match:
2227 return runs, None
2228 if not full_text[match.end():].strip():
2229 return runs, None
2230
2231 marker = match.group('marker')
2232 marker_run = _first_nonspace_run(runs) or {}
2233 prefix_runs = _take_leading_chars_from_runs(runs, match.end())
2234 replacement_prefix = _TEXT_BULLET_MARKERS.get(marker, marker) + (match.group('space') or ' ')
2235 replacement_runs = [{**marker_run, 'text': replacement_prefix}] if marker_run else []
2236 bullet = {
2237 'char': _TEXT_BULLET_MARKERS.get(marker, marker),
2238 'fill': marker_run.get('fill'),
2239 'fill_raw': marker_run.get('fill_raw'),
2240 'opacity': marker_run.get('opacity'),
2241 'source_prefix_width_px': _estimate_text_runs_width(prefix_runs, include_headroom=False),
2242 'margin_px': max(
2243 _estimate_text_runs_width(replacement_runs, include_headroom=False),
2244 8.0,
2245 ),
2246 }
2247 stripped = _strip_leading_chars_from_runs(runs, match.end())
2248 return (stripped or runs), bullet
2249
2250
2251 def _bullet_margin_px(bullet: dict[str, Any], font_size: float) -> float:
2252 try:
2253 return float(bullet.get('margin_px', 0.0))
2254 except (TypeError, ValueError):
2255 return max(font_size * 0.95, 12.0)
2256
2257
2258 def _bullet_indent_px(bullet: dict[str, Any], font_size: float) -> float:
2259 return -_bullet_margin_px(bullet, font_size)
2260
2261
2262 def _build_bullet_xml(
2263 bullet: dict[str, Any] | None,
2264 ctx: ConvertContext | None,
2265 ) -> str:
2266 if not bullet:
2267 return ''
2268 fill = bullet.get('fill')
2269 fill_raw = bullet.get('fill_raw')
2270 color, color_alpha = parse_svg_color(
2271 fill_raw if isinstance(fill_raw, str) else ''
2272 )
2273 if color is None and isinstance(fill, str):
2274 color = parse_hex_color(fill)
2275 if color:
2276 opacity = combine_opacity(bullet.get('opacity'), color_alpha)
2277 alpha_xml = (
2278 f'<a:alphaMod val="{quantize_ooxml_alpha(opacity)}"/>'
2279 if opacity is not None else ''
2280 )
2281 theme_spec = ctx.theme_color_spec if ctx is not None else None
2282 color_xml = (
2283 f'<a:buClr>{color_node_xml(color, theme_spec, "text", alpha_xml)}</a:buClr>'
2284 )
2285 else:
2286 color_xml = '<a:buClrTx/>'
2287 return (
2288 f'{color_xml}<a:buSzTx/><a:buFontTx/>'
2289 f'<a:buChar char="{_xml_escape(str(bullet.get("char", "•")))}"/>'
2290 )
2291
2292
2293 def _paragraph_pr_xml(
2294 *,
2295 algn: str,
2296 font_size: float,
2297 body_xml: str = '',
2298 bullet: dict[str, Any] | None = None,
2299 ctx: ConvertContext | None = None,
2300 rtl: bool = False,
2301 ) -> str:
2302 attrs = f'algn="{algn}"'
2303 if rtl:
2304 attrs += ' rtl="1"'
2305 if bullet:
2306 margin = px_to_emu(_bullet_margin_px(bullet, font_size))
2307 indent = px_to_emu(_bullet_indent_px(bullet, font_size))
2308 attrs += f' marL="{margin}" indent="{indent}"'
2309 return f'<a:pPr {attrs}>{body_xml}{_build_bullet_xml(bullet, ctx)}</a:pPr>'
2310
2311
2312 def _estimate_bullet_line_width(
2313 runs: list[dict[str, Any]],
2314 default_fonts: dict[str, str],
2315 ctx: ConvertContext,
2316 ) -> float:
2317 line_runs, bullet = _extract_text_bullet(runs)
2318 line_runs = _coalesce_text_runs(line_runs, default_fonts, ctx)
2319 width = _estimate_text_runs_width(line_runs, include_headroom=False)
2320 if bullet:
2321 fs_px = float(line_runs[0].get('font_size', 16)) if line_runs else 16.0
2322 width += _bullet_margin_px(bullet, fs_px)
2323 return width
2324
2325
2326 def _textbox_padding(font_size: float) -> float:
2327 """Return small text-frame slack without visibly lengthening the box."""
2328 return max(
2329 _TEXTBOX_PADDING_MIN_PX,
2330 min(_TEXTBOX_PADDING_MAX_PX, font_size * _TEXTBOX_PADDING_RATIO),
2331 )
2332
2333
2334 def drawingml_text_frame_width_emu(
2335 text_width: float,
2336 font_size: float,
2337 ) -> int:
2338 """Return the exact horizontal extent used by a generated text frame."""
2339 return px_to_emu(text_width + _textbox_padding(font_size) * 2)
2340
2341
2342 def _text_opacity_ratio(value: str | None) -> float:
2343 """Parse a text opacity component and clamp it to the SVG ``0..1`` range."""
2344 if value is None:
2345 return 1.0
2346 return parse_project_opacity(value)
2347
2348
2349 def _override_run_attrs(
2350 parent_attrs: dict[str, Any],
2351 tspan: ET.Element,
2352 ctx: ConvertContext,
2353 ) -> dict[str, Any]:
2354 """Layer a tspan's styling attributes over the inherited run attrs."""
2355 run_attrs = dict(parent_attrs)
2356 inline_style = parse_inline_style(tspan.get('style'))
2357
2358 def tspan_attr(name: str) -> str | None:
2359 return inline_style.get(name) or tspan.get(name)
2360
2361 object_opacity = float(run_attrs.get('_object_opacity', 1.0))
2362 fill_opacity = float(run_attrs.get('_fill_opacity', 1.0))
2363 stroke_opacity = float(run_attrs.get('_stroke_opacity', 1.0))
2364 if tspan_attr('opacity') is not None:
2365 object_opacity *= _text_opacity_ratio(tspan_attr('opacity'))
2366 if tspan_attr('fill-opacity') is not None:
2367 fill_opacity = _text_opacity_ratio(tspan_attr('fill-opacity'))
2368 if tspan_attr('stroke-opacity') is not None:
2369 stroke_opacity = _text_opacity_ratio(tspan_attr('stroke-opacity'))
2370 run_attrs['_object_opacity'] = object_opacity
2371 run_attrs['_fill_opacity'] = fill_opacity
2372 run_attrs['_stroke_opacity'] = stroke_opacity
2373 effective_fill_opacity = object_opacity * fill_opacity
2374 effective_stroke_opacity = object_opacity * stroke_opacity
2375 run_attrs['opacity'] = (
2376 effective_fill_opacity if effective_fill_opacity < 1.0 else None
2377 )
2378 run_attrs['stroke_opacity'] = (
2379 effective_stroke_opacity if effective_stroke_opacity < 1.0 else None
2380 )
2381
2382 if tspan_attr('font-weight'):
2383 run_attrs['font_weight'] = parse_project_font_weight(
2384 tspan_attr('font-weight')
2385 ).canonical
2386 raw_baseline_shift = tspan.get('baseline-shift')
2387 if raw_baseline_shift is not None:
2388 run_attrs['baseline_shift'] = int(
2389 parse_project_baseline_shift(raw_baseline_shift).value
2390 )
2391 if tspan_attr('fill'):
2392 child_fill = tspan_attr('fill')
2393 run_attrs['fill_raw'] = child_fill
2394 c = parse_hex_color(child_fill)
2395 if c:
2396 run_attrs['fill'] = c
2397 if tspan_attr('stroke'):
2398 run_attrs['stroke_raw'] = tspan_attr('stroke')
2399 if tspan_attr('stroke-width'):
2400 run_attrs['stroke_width'] = parse_svg_length(
2401 tspan_attr('stroke-width'),
2402 run_attrs.get('stroke_width', 1.0),
2403 font_size=float(run_attrs.get('font_size', 16)),
2404 )
2405 resolved_font_size = ctx.text_font_sizes.get(id(tspan))
2406 if resolved_font_size is not None:
2407 run_attrs['font_size'] = resolved_font_size * ctx.scale_y
2408 elif tspan_attr('font-size'):
2409 run_attrs['font_size'] = parse_svg_length(
2410 tspan_attr('font-size'),
2411 run_attrs['font_size'],
2412 font_size=float(run_attrs.get('font_size', 16)),
2413 )
2414 if tspan_attr('font-family'):
2415 run_attrs['font_family'] = tspan_attr('font-family')
2416 if tspan_attr('font-style'):
2417 run_attrs['font_style'] = parse_project_font_style(
2418 tspan_attr('font-style')
2419 ).canonical
2420 if tspan_attr('text-decoration'):
2421 run_attrs['text_decoration'] = parse_project_text_decoration(
2422 tspan_attr('text-decoration')
2423 ).canonical
2424 resolved_letter_spacing = ctx.text_letter_spacings.get(id(tspan))
2425 if resolved_letter_spacing is not None:
2426 run_attrs['letter_spacing'] = resolved_letter_spacing * ctx.scale_x
2427 elif tspan_attr('letter-spacing'):
2428 run_attrs['letter_spacing'] = parse_project_letter_spacing(
2429 tspan_attr('letter-spacing'),
2430 font_size=float(run_attrs.get('font_size', 16)),
2431 scale_x=float(run_attrs.get('_scale_x', 1.0)),
2432 ).value
2433 return run_attrs
2434
2435
2436 def _collect_tspan_runs(
2437 tspan: ET.Element,
2438 inherited_attrs: dict[str, Any],
2439 ctx: ConvertContext,
2440 inherited_xml_space: str = 'default',
2441 ) -> list[dict[str, Any]]:
2442 """Recursively turn one inline SVG subtree into DrawingML text runs."""
2443 return _collect_inline_runs(
2444 tspan,
2445 inherited_attrs,
2446 ctx,
2447 inherited_xml_space,
2448 )
2449
2450
2451 def _collect_inline_runs(
2452 container: ET.Element,
2453 inherited_attrs: dict[str, Any],
2454 ctx: ConvertContext,
2455 inherited_xml_space: str = 'default',
2456 inherited_hyperlink: dict[str, str] | None = None,
2457 ) -> list[dict[str, Any]]:
2458 """Collect nested ``tspan``/``a`` content with style and link inheritance."""
2459 runs: list[dict[str, Any]] = []
2460 own_attrs = _override_run_attrs(inherited_attrs, container, ctx)
2461 own_xml_space = resolve_project_xml_space(container, inherited_xml_space)
2462 container_tag = container.tag.replace(f'{{{SVG_NS}}}', '')
2463 own_hyperlink = inherited_hyperlink
2464 if container_tag == 'a':
2465 own_hyperlink = hyperlink_run_metadata(
2466 ctx,
2467 svg_hyperlink_href(container),
2468 )
2469
2470 if container.text:
2471 run = {
2472 **own_attrs,
2473 'text': container.text,
2474 '_xml_space': own_xml_space,
2475 }
2476 if own_hyperlink is not None:
2477 run.update(own_hyperlink)
2478 inline_formula = container.get(_INLINE_FORMULA_ATTR)
2479 if inline_formula is not None:
2480 run[_INLINE_FORMULA_KEY] = inline_formula
2481 runs.append(run)
2482
2483 for child in container:
2484 child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
2485 if child_tag in {'tspan', 'a'}:
2486 runs.extend(
2487 _collect_inline_runs(
2488 child,
2489 own_attrs,
2490 ctx,
2491 own_xml_space,
2492 own_hyperlink,
2493 )
2494 )
2495 if child.tail:
2496 tail_run = {
2497 **own_attrs,
2498 'text': child.tail,
2499 '_xml_space': own_xml_space,
2500 }
2501 if own_hyperlink is not None:
2502 tail_run.update(own_hyperlink)
2503 runs.append(tail_run)
2504
2505 return runs
2506
2507
2508 def _build_text_runs(
2509 elem: ET.Element,
2510 parent_attrs: dict[str, Any],
2511 ctx: ConvertContext,
2512 ) -> list[dict[str, Any]]:
2513 """Build a list of text runs from a <text> element, handling <tspan> children.
2514
2515 Each run carries text plus resolved paint, typography, tracking, and baseline
2516 shift. Nested tspans are walked recursively so inline format changes still
2517 produce distinct runs.
2518 """
2519 runs: list[dict[str, Any]] = []
2520 xml_space = resolve_project_xml_space(elem)
2521
2522 if elem.text:
2523 runs.append({
2524 **parent_attrs,
2525 'text': elem.text,
2526 '_xml_space': xml_space,
2527 })
2528
2529 for child in elem:
2530 child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
2531 if child_tag in {'tspan', 'a'}:
2532 runs.extend(_collect_inline_runs(
2533 child,
2534 parent_attrs,
2535 ctx,
2536 xml_space,
2537 ))
2538 if child.tail:
2539 runs.append({
2540 **parent_attrs,
2541 'text': child.tail,
2542 '_xml_space': xml_space,
2543 })
2544
2545 return _normalize_text_run_whitespace(runs)
2546
2547
2548 def _build_text_fill_xml(
2549 fill: str,
2550 fill_raw: str,
2551 opacity: float | None,
2552 ctx: ConvertContext | None,
2553 ) -> str:
2554 """Build DrawingML fill XML for a text run."""
2555 if fill_raw.strip().lower() in ('none', 'transparent'):
2556 return '<a:noFill/>'
2557
2558 paint_id = resolve_url_id(fill_raw)
2559 if paint_id and ctx and paint_id in ctx.defs:
2560 paint = ctx.defs[paint_id]
2561 paint_tag = paint.tag.rsplit('}', 1)[-1]
2562 if paint_tag in {'linearGradient', 'radialGradient'}:
2563 return build_gradient_fill(
2564 paint,
2565 opacity,
2566 ctx.theme_color_spec,
2567 "text",
2568 )
2569 if paint_tag == 'pattern':
2570 mode, image = resolve_project_text_image_fill(paint)
2571 source = load_project_image_source(image, ctx.svg_dir)
2572 r_id = _register_image_media(
2573 ctx,
2574 source.img_format,
2575 source.img_data,
2576 reuse_text_fill=True,
2577 )
2578 blip_xml = _build_image_blip_xml(r_id, opacity)
2579 if mode == 'stretch':
2580 fill_mode_xml = '<a:stretch><a:fillRect/></a:stretch>'
2581 else:
2582 fill_mode_xml = '<a:tile/>'
2583 return f'<a:blipFill>{blip_xml}{fill_mode_xml}</a:blipFill>'
2584
2585 parsed_color, color_alpha = parse_svg_color(fill_raw)
2586 fill = parsed_color or fill
2587 opacity = combine_opacity(opacity, color_alpha)
2588 alpha_xml = ''
2589 if opacity is not None:
2590 alpha_xml = f'<a:alphaMod val="{quantize_ooxml_alpha(opacity)}"/>'
2591 theme_spec = ctx.theme_color_spec if ctx is not None else None
2592 return (
2593 '<a:solidFill>'
2594 f'{color_node_xml(fill, theme_spec, "text", alpha_xml)}'
2595 '</a:solidFill>'
2596 )
2597
2598
2599 def _build_text_outline_xml(
2600 run: dict[str, Any],
2601 ctx: ConvertContext | None,
2602 ) -> str:
2603 """Build DrawingML outline XML for a text run from SVG stroke attributes."""
2604 stroke_raw = run.get('stroke_raw')
2605 if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'):
2606 return ''
2607
2608 color, color_alpha = parse_svg_color(stroke_raw)
2609 if not color:
2610 return ''
2611
2612 stroke_width = _f(str(run.get('stroke_width', 1.0)), 1.0)
2613 stroke_opacity = combine_opacity(run.get('stroke_opacity'), color_alpha)
2614 alpha_xml = ''
2615 if stroke_opacity is not None:
2616 alpha_xml = (
2617 f'<a:alphaMod val="{quantize_ooxml_alpha(stroke_opacity)}"/>'
2618 )
2619
2620 theme_spec = ctx.theme_color_spec if ctx is not None else None
2621 return (
2622 f'<a:ln w="{px_to_emu(stroke_width)}">'
2623 '<a:solidFill>'
2624 f'{color_node_xml(color, theme_spec, "stroke", alpha_xml)}'
2625 '</a:solidFill>'
2626 '</a:ln>'
2627 )
2628
2629
2630 def _build_run_properties_xml(
2631 run: dict[str, Any],
2632 default_fonts: dict[str, str],
2633 ctx: ConvertContext | None = None,
2634 effect_xml: str = '',
2635 fixed_font_family: str | None = None,
2636 ) -> str:
2637 """Build the final ``a:rPr`` used to compare and emit one text run."""
2638 text = str(run['text'])
2639 fill = run.get('fill', '000000')
2640 fill_raw = run.get('fill_raw', '')
2641 fw = run.get('font_weight', '400')
2642 fs_px = run.get('font_size', 16)
2643 fstyle = run.get('font_style', '')
2644 ff = run.get('font_family', '')
2645 letter_spacing_px = float(run.get('letter_spacing', 0.0) or 0.0)
2646 baseline_shift = int(run.get('baseline_shift', 0) or 0)
2647 opacity = run.get('opacity')
2648
2649 text_dec = run.get('text_decoration', '')
2650
2651 # Exported font size = fs_px * FONT_PX_TO_HUNDREDTHS_PT hundredths-of-pt,
2652 # rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt
2653 # / integer snapping — whatever the px works out to is the size, e.g.
2654 # 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt.
2655 sz = font_px_to_hpt(fs_px)
2656 b_attr = ' b="1"' if parse_project_font_weight(fw).value else ''
2657 i_attr = ' i="1"' if fstyle == 'italic' else ''
2658 underline, strike = parse_project_text_decoration(
2659 text_dec or 'none'
2660 ).value
2661 u_attr = ' u="sng"' if underline else ''
2662 strike_attr = ' strike="sngStrike"' if strike else ''
2663 spc_attr = _letter_spacing_to_drawingml_spc(letter_spacing_px)
2664 baseline_attr = f' baseline="{baseline_shift}"' if baseline_shift else ''
2665
2666 fonts = parse_font_family(ff) if ff else default_fonts
2667 run_fonts = (
2668 {
2669 'latin': fixed_font_family,
2670 'ea': fixed_font_family,
2671 'cs': fixed_font_family,
2672 }
2673 if fixed_font_family is not None
2674 else theme_font_tokens(
2675 fonts,
2676 ctx.theme_font_spec if ctx is not None else None,
2677 ) or resolve_text_run_fonts(text, fonts)
2678 )
2679 lang = str(run.get('_language_override') or detect_text_lang(
2680 text,
2681 ctx.primary_language if ctx is not None else None,
2682 ))
2683 rtl_xml = (
2684 '\n<a:rtl val="1"/>'
2685 if text_has_rtl_characters(text)
2686 else ''
2687 )
2688
2689 fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
2690 outline_xml = _build_text_outline_xml(run, ctx)
2691 relationship_id = run.get(HYPERLINK_RID_KEY)
2692 hyperlink_xml = (
2693 hyperlink_click_xml(
2694 str(relationship_id),
2695 str(run.get(HYPERLINK_ACTION_KEY))
2696 if run.get(HYPERLINK_ACTION_KEY) is not None
2697 else None,
2698 )
2699 if relationship_id is not None
2700 else ''
2701 )
2702
2703 return f'''<a:rPr lang="{lang}" sz="{sz}"{b_attr}{i_attr}{u_attr}{strike_attr}{spc_attr}{baseline_attr} dirty="0">
2704 {outline_xml}
2705 {fill_xml}
2706 {effect_xml}
2707 <a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
2708 <a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
2709 <a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>
2710 {hyperlink_xml}{rtl_xml}
2711 </a:rPr>'''
2712
2713
2714 def _coalesce_text_runs(
2715 runs: list[dict[str, Any]],
2716 default_fonts: dict[str, str],
2717 ctx: ConvertContext | None,
2718 ) -> list[dict[str, Any]]:
2719 """Join adjacent runs that PowerPoint sees as one formatting run."""
2720 merged: list[dict[str, Any]] = []
2721 previous_properties: str | None = None
2722 for run in runs:
2723 if run.get('_line_break'):
2724 merged.append({'_line_break': True})
2725 previous_properties = None
2726 continue
2727 text = str(run.get('text', ''))
2728 if not text:
2729 continue
2730 if run.get(_INLINE_FORMULA_KEY) is not None:
2731 merged.append({**run, 'text': text})
2732 previous_properties = None
2733 continue
2734 properties = _build_run_properties_xml(run, default_fonts, ctx)
2735 if (
2736 merged
2737 and merged[-1].get(_INLINE_FORMULA_KEY) is None
2738 and merged[-1].get(HYPERLINK_RID_KEY) == run.get(HYPERLINK_RID_KEY)
2739 and merged[-1].get(HYPERLINK_ACTION_KEY) == run.get(HYPERLINK_ACTION_KEY)
2740 and properties == previous_properties
2741 ):
2742 candidate = {
2743 **merged[-1],
2744 'text': str(merged[-1].get('text', '')) + text,
2745 }
2746 candidate_properties = _build_run_properties_xml(
2747 candidate,
2748 default_fonts,
2749 ctx,
2750 )
2751 if candidate_properties == previous_properties:
2752 merged[-1] = candidate
2753 previous_properties = candidate_properties
2754 continue
2755 merged.append({**run, 'text': text})
2756 previous_properties = properties
2757 return merged
2758
2759
2760 def _text_axis_transform(ctx: ConvertContext) -> tuple[bool, bool, bool]:
2761 """Return whether text can consume the context matrix and its axis flips."""
2762 if not ctx.use_transform_matrix:
2763 return False, False, False
2764 a, b, c, d, _e, _f = ctx.transform_matrix
2765 if abs(b) > 1e-9 or abs(c) > 1e-9:
2766 return False, False, False
2767 return True, a < 0, d < 0
2768
2769
2770 def _build_run_xml(
2771 run: dict[str, Any],
2772 default_fonts: dict[str, str],
2773 ctx: ConvertContext | None = None,
2774 effect_xml: str = '',
2775 ) -> str:
2776 """Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
2777 if run.get('_line_break'):
2778 return '<a:br/>'
2779 text = str(run['text'])
2780 inline_formula = run.get(_INLINE_FORMULA_KEY)
2781 if inline_formula is not None:
2782 fill_raw = str(run.get('fill_raw') or f"#{run.get('fill', '000000')}")
2783 fill_color, fill_alpha = parse_svg_color(fill_raw)
2784 if fill_color is None or fill_alpha <= 0:
2785 raise ValueError(
2786 'inline formula text requires one visible solid fill color'
2787 )
2788 math_run = {
2789 **run,
2790 'font_family': 'Cambria Math',
2791 'font_weight': '400',
2792 'font_style': 'normal',
2793 'text_decoration': 'none',
2794 'letter_spacing': 0.0,
2795 'stroke_raw': '',
2796 'stroke_opacity': None,
2797 '_language_override': (
2798 ctx.primary_language
2799 if ctx is not None and ctx.primary_language is not None
2800 else 'en-US'
2801 ),
2802 }
2803 properties_xml = _build_run_properties_xml(
2804 math_run,
2805 default_fonts,
2806 ctx,
2807 fixed_font_family='Cambria Math',
2808 )
2809 from ..native_objects.inline_formula import build_inline_formula_xml
2810 return build_inline_formula_xml(str(inline_formula), properties_xml)
2811 properties_xml = _build_run_properties_xml(
2812 run,
2813 default_fonts,
2814 ctx,
2815 effect_xml,
2816 )
2817 space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else ''
2818
2819 return f'''<a:r>
2820 {properties_xml}
2821 <a:t{space_attr}>{_xml_escape(text)}</a:t>
2822 </a:r>'''
2823
2824
2825 def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
2826 """Convert SVG <text> to DrawingML text shape with multi-run support."""
2827 raw_x = svg_length_x(elem.get('x'), ctx)
2828 raw_y = svg_length_y(elem.get('y'), ctx)
2829 use_axis_transform, reflect_x, reflect_y = _text_axis_transform(ctx)
2830 if use_axis_transform:
2831 x, y = transform_point(ctx.transform_matrix, raw_x, raw_y)
2832 else:
2833 x = ctx_x(raw_x, ctx)
2834 y = ctx_y(raw_y, ctx)
2835 resolved_font_size = ctx.text_font_sizes.get(id(elem))
2836 font_size = (
2837 resolved_font_size * ctx.scale_y
2838 if resolved_font_size is not None
2839 else parse_svg_length(
2840 _get_attr(elem, 'font-size', ctx),
2841 16,
2842 font_size=16,
2843 ) * ctx.scale_y
2844 )
2845 font_weight = parse_project_font_weight(
2846 _get_attr(elem, 'font-weight', ctx) or '400'
2847 ).canonical
2848 font_family_str = _get_attr(elem, 'font-family', ctx) or ''
2849 text_anchor = parse_project_text_anchor(
2850 _get_attr(elem, 'text-anchor', ctx) or 'start'
2851 ).canonical
2852 if reflect_x:
2853 text_anchor = {
2854 'start': 'end',
2855 'end': 'start',
2856 }.get(text_anchor, text_anchor)
2857 fill_raw = _get_attr(elem, 'fill', ctx) or '#000000'
2858 fill_color = parse_hex_color(fill_raw) or '000000'
2859 opacity = get_fill_opacity(elem, ctx)
2860 object_opacity = get_element_opacity(elem, ctx)
2861 object_opacity = 1.0 if object_opacity is None else object_opacity
2862 fill_opacity = _text_opacity_ratio(_get_attr(elem, 'fill-opacity', ctx))
2863 stroke_raw = _get_attr(elem, 'stroke', ctx) or ''
2864 stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1.0)
2865 stroke_opacity = get_stroke_opacity(elem, ctx)
2866 stroke_opacity_value = _text_opacity_ratio(_get_attr(elem, 'stroke-opacity', ctx))
2867 font_style = parse_project_font_style(
2868 _get_attr(elem, 'font-style', ctx) or 'normal'
2869 ).canonical
2870 text_decoration = parse_project_text_decoration(
2871 _get_attr(elem, 'text-decoration', ctx) or 'none'
2872 ).canonical
2873 raw_letter_spacing = _get_attr(elem, 'letter-spacing', ctx)
2874 resolved_letter_spacing = ctx.text_letter_spacings.get(id(elem))
2875 if resolved_letter_spacing is not None:
2876 letter_spacing_px = resolved_letter_spacing * ctx.scale_x
2877 elif raw_letter_spacing is not None:
2878 letter_spacing_px = parse_project_letter_spacing(
2879 raw_letter_spacing,
2880 font_size=font_size,
2881 scale_x=ctx.scale_x or 1.0,
2882 ).value
2883 else:
2884 letter_spacing_px = 0.0
2885
2886 fonts = parse_font_family(font_family_str)
2887
2888 parent_attrs: dict[str, Any] = {
2889 'fill': fill_color,
2890 'fill_raw': fill_raw,
2891 'font_weight': font_weight,
2892 'font_size': font_size,
2893 'font_family': font_family_str,
2894 'font_style': font_style,
2895 'text_decoration': text_decoration,
2896 'letter_spacing': letter_spacing_px,
2897 'baseline_shift': 0,
2898 '_scale_x': ctx.scale_x or 1.0,
2899 '_object_opacity': object_opacity,
2900 '_fill_opacity': fill_opacity,
2901 '_stroke_opacity': stroke_opacity_value,
2902 'opacity': opacity,
2903 'stroke_raw': stroke_raw,
2904 'stroke_width': stroke_width,
2905 'stroke_opacity': stroke_opacity,
2906 }
2907
2908 # Single-frame modes annotate conservative dy-stacked text with one base
2909 # line height. Semantic paragraphs become <a:p>; authored visual rows
2910 # either become <a:br/> (preserve) or join for wrapping (reflow).
2911 line_height_attr = (
2912 elem.get('data-paragraph-line-height')
2913 if ctx.text_flow != TEXT_FLOW_SPLIT
2914 else None
2915 )
2916 line_height_px = _f(line_height_attr) if line_height_attr is not None else None
2917 paragraph_runs: list[list[dict[str, Any]]] | None = None
2918 paragraph_space_before: list[float] = []
2919 paragraph_bullets: list[dict[str, Any] | None] = []
2920 # Per-tspan widths (visual lines as the deck author drew them) regardless
2921 # of how many merge into one <a:p>; used to size the textbox so PowerPoint
2922 # has room to wrap text to the SVG's original line widths.
2923 visual_line_widths: list[float] = []
2924 visual_line_runs: list[list[dict[str, Any]]] = []
2925 if line_height_px is not None and line_height_px > 0:
2926 xml_space = resolve_project_xml_space(elem)
2927 paragraph_runs = []
2928 for child in elem:
2929 if child.tag != f'{{{SVG_NS}}}tspan':
2930 continue
2931 line_runs = _collect_tspan_runs(
2932 child,
2933 parent_attrs,
2934 ctx,
2935 xml_space,
2936 )
2937 line_runs = _normalize_text_run_whitespace(line_runs)
2938 if not line_runs:
2939 continue
2940 visual_line_runs.append(line_runs)
2941 visual_line_widths.append(
2942 _estimate_bullet_line_width(line_runs, fonts, ctx)
2943 )
2944 soft_break = child.get('data-paragraph-soft-break') == '1'
2945 line_break = child.get('data-paragraph-line-break') == '1'
2946 if line_break and paragraph_runs:
2947 paragraph_runs[-1].append({'_line_break': True})
2948 paragraph_runs[-1].extend(line_runs)
2949 elif soft_break and paragraph_runs:
2950 # Append to the previous paragraph. A Latin line-wrap needs a
2951 # space to keep two words apart (SVG used a dy break, not
2952 # punctuation); CJK wraps mid-sentence with no inter-character
2953 # space, so a joining space there is a spurious artifact.
2954 prev = paragraph_runs[-1]
2955 prev_text = prev[-1]['text'] if prev else ''
2956 next_text = line_runs[0]['text']
2957 boundary_is_cjk = (
2958 (prev_text and is_cjk_char(prev_text[-1]))
2959 or (next_text and is_cjk_char(next_text[0]))
2960 )
2961 if prev and not prev_text.endswith(' ') \
2962 and not next_text.startswith(' ') \
2963 and not boundary_is_cjk:
2964 joining_space = {
2965 **prev[-1],
2966 'text': ' ',
2967 'letter_spacing': 0.0,
2968 }
2969 joining_space.pop(_INLINE_FORMULA_KEY, None)
2970 joining_space.pop(HYPERLINK_RID_KEY, None)
2971 joining_space.pop(HYPERLINK_ACTION_KEY, None)
2972 prev.append(joining_space)
2973 prev.extend(line_runs)
2974 else:
2975 paragraph_runs.append(line_runs)
2976 sb_attr = child.get('data-paragraph-space-before')
2977 paragraph_space_before.append(_f(sb_attr) if sb_attr else 0.0)
2978 if not paragraph_runs:
2979 paragraph_runs = None
2980 paragraph_space_before = []
2981 visual_line_widths = []
2982 visual_line_runs = []
2983 else:
2984 stripped_paragraphs: list[list[dict[str, Any]]] = []
2985 for line_runs in paragraph_runs:
2986 stripped_runs, bullet = _extract_text_bullet(line_runs)
2987 stripped_paragraphs.append(
2988 _coalesce_text_runs(stripped_runs, fonts, ctx)
2989 )
2990 paragraph_bullets.append(bullet)
2991 paragraph_runs = stripped_paragraphs
2992
2993 if paragraph_runs is not None:
2994 runs = [r for line in paragraph_runs for r in line]
2995 else:
2996 runs = _build_text_runs(elem, parent_attrs, ctx)
2997 runs, single_bullet = _extract_text_bullet(runs)
2998 runs = _coalesce_text_runs(runs, fonts, ctx)
2999
3000 is_placeholder_carrier = (
3001 (elem.get('data-pptx-carrier') or '').strip().lower() == 'true'
3002 )
3003 full_text = ''.join(str(r.get('text', '')) for r in runs) if runs else ''
3004 if not full_text.strip():
3005 if not is_placeholder_carrier:
3006 return None
3007 # A declared carrier must compile to one native text shape even when its
3008 # authored visual is blank. U+200B is invisible but survives DrawingML.
3009 runs = [{**parent_attrs, 'text': '\u200b'}]
3010 paragraph_runs = None
3011 paragraph_space_before = []
3012 paragraph_bullets = []
3013 visual_line_widths = []
3014 visual_line_runs = []
3015 single_bullet = None
3016
3017 # Estimate text dimensions
3018 if paragraph_runs is not None:
3019 # Use the widest authored visual line, not a reflow-joined paragraph.
3020 text_width = max(visual_line_widths) if visual_line_widths else 0.0
3021 line_extents = [
3022 _text_line_vertical_extent(line, font_size)
3023 for line in visual_line_runs
3024 ]
3025 text_height = sum(paragraph_space_before)
3026 for ascent, descent, has_formula in line_extents[:-1]:
3027 line_advance = line_height_px
3028 if has_formula:
3029 line_advance = max(
3030 line_advance,
3031 _native_text_line_frame_height(
3032 ascent,
3033 descent,
3034 font_size,
3035 ),
3036 )
3037 text_height += line_advance
3038 first_line_ascent = line_extents[0][0]
3039 last_ascent, last_descent, last_has_formula = line_extents[-1]
3040 last_line_height = font_size * 1.5
3041 if last_has_formula:
3042 last_line_height = max(
3043 last_line_height,
3044 _native_text_line_frame_height(
3045 last_ascent,
3046 last_descent,
3047 font_size,
3048 ),
3049 )
3050 text_height += last_line_height
3051 else:
3052 text_width = _estimate_text_runs_width(runs)
3053 if single_bullet:
3054 fs_px = float(runs[0].get('font_size', font_size)) if runs else font_size
3055 text_width += _bullet_margin_px(single_bullet, fs_px)
3056 first_line_ascent, line_descent, has_formula = (
3057 _text_line_vertical_extent(runs, font_size)
3058 )
3059 text_height = font_size * 1.5
3060 if has_formula:
3061 text_height = max(
3062 text_height,
3063 _native_text_line_frame_height(
3064 first_line_ascent,
3065 line_descent,
3066 font_size,
3067 ),
3068 )
3069 padding = _textbox_padding(font_size)
3070
3071 # Adjust position based on text-anchor. This first box follows the visible
3072 # glyph baseline and remains useful for reconstructing imported text-body
3073 # insets when data-pptx-frame supplies the owning PowerPoint shape frame.
3074 if text_anchor == 'middle':
3075 box_x = x - text_width / 2 - padding
3076 elif text_anchor == 'end':
3077 box_x = x - text_width - padding
3078 else:
3079 box_x = x - padding
3080
3081 box_y = y - first_line_ascent
3082 box_w = text_width + padding * 2
3083 box_h = text_height + padding
3084 if reflect_y:
3085 box_y = 2 * y - box_y - box_h
3086
3087 visual_box_x = box_x
3088 visual_box_y = box_y
3089 exact_text_frame = None
3090 exact_text_insets: tuple[float, float, float] | None = None
3091 if elem.get('data-pptx-frame') is not None:
3092 _preset, _guides, exact_text_frame = _parse_preset_geometry_metadata(elem)
3093 if exact_text_frame is None:
3094 raise ValueError('data-pptx-frame did not resolve to a text frame')
3095 raw_frame_x, raw_frame_y, raw_frame_w, raw_frame_h = exact_text_frame
3096 if use_axis_transform:
3097 frame_x_1, frame_y_1 = transform_point(
3098 ctx.transform_matrix,
3099 raw_frame_x,
3100 raw_frame_y,
3101 )
3102 frame_x_2, frame_y_2 = transform_point(
3103 ctx.transform_matrix,
3104 raw_frame_x + raw_frame_w,
3105 raw_frame_y + raw_frame_h,
3106 )
3107 else:
3108 frame_x_1 = ctx_x(raw_frame_x, ctx)
3109 frame_x_2 = ctx_x(raw_frame_x + raw_frame_w, ctx)
3110 frame_y_1 = ctx_y(raw_frame_y, ctx)
3111 frame_y_2 = ctx_y(raw_frame_y + raw_frame_h, ctx)
3112 box_x = min(frame_x_1, frame_x_2)
3113 box_y = min(frame_y_1, frame_y_2)
3114 box_w = abs(frame_x_2 - frame_x_1)
3115 box_h = abs(frame_y_2 - frame_y_1)
3116 top_inset = visual_box_y - box_y
3117 if text_anchor == 'start':
3118 left_inset = x - box_x
3119 right_inset = 0.0
3120 elif text_anchor == 'end':
3121 left_inset = 0.0
3122 right_inset = box_x + box_w - x
3123 else:
3124 center_delta = x - (box_x + box_w / 2)
3125 left_inset = max(0.0, center_delta * 2)
3126 right_inset = max(0.0, -center_delta * 2)
3127 exact_text_insets = (left_inset, top_inset, right_inset)
3128
3129 text_transform = elem.get('transform', '')
3130 text_operations = (
3131 parse_transform_operations(text_transform)
3132 if text_transform else ()
3133 )
3134 translate_only = bool(text_operations) and all(
3135 name == 'translate' for name, _args in text_operations
3136 )
3137 rotate_only = (
3138 len(text_operations) == 1
3139 and text_operations[0][0] == 'rotate'
3140 )
3141 if text_operations and not (translate_only or rotate_only):
3142 raise ValueError(
3143 'Text transform must be a translate-only list or one rotate operation'
3144 )
3145 if translate_only and not ctx.use_transform_matrix:
3146 a, b, c, d, e, f = parse_transform_matrix(text_transform)
3147 # A pure-translate transform on a text element (hand-authored, or written
3148 # by a live-preview move) was otherwise ignored here, drifting the text.
3149 # Absorb the translation into the frame position.
3150 if (
3151 abs(a - 1.0) < 1e-9 and abs(b) < 1e-9
3152 and abs(c) < 1e-9 and abs(d - 1.0) < 1e-9
3153 ):
3154 sx = ctx.scale_x or 1.0
3155 sy = ctx.scale_y or 1.0
3156 raw_box_x = (box_x - ctx.translate_x) / sx
3157 raw_box_y = (box_y - ctx.translate_y) / sy
3158 box_x = ctx.translate_x + sx * (a * raw_box_x + e)
3159 box_y = ctx.translate_y + sy * (d * raw_box_y + f)
3160 elif translate_only and use_axis_transform:
3161 _a, _b, _c, _d, e, f = parse_transform_matrix(text_transform)
3162 matrix_a, matrix_b, matrix_c, matrix_d, _matrix_e, _matrix_f = (
3163 ctx.transform_matrix
3164 )
3165 box_x += matrix_a * e + matrix_c * f
3166 box_y += matrix_b * e + matrix_d * f
3167
3168 # Text rotation. SVG's rotate(angle [cx cy]) rotates around (cx, cy), but
3169 # DrawingML's <a:xfrm rot="..."> rotates the shape around its own center.
3170 # When a pivot is given (and differs from the box center), translate the
3171 # box so its center lands where SVG would place the rotated visual center —
3172 # otherwise rotated y-axis labels etc. drift to the wrong location.
3173 text_rot = 0
3174 if rotate_only:
3175 rotate_args = text_operations[0][1]
3176 angle_deg = rotate_args[0]
3177 if reflect_x != reflect_y:
3178 angle_deg = -angle_deg
3179 text_rot = int(angle_deg * ANGLE_UNIT)
3180 if len(rotate_args) == 3:
3181 if use_axis_transform:
3182 pivot_x, pivot_y = transform_point(
3183 ctx.transform_matrix,
3184 rotate_args[1],
3185 rotate_args[2],
3186 )
3187 else:
3188 pivot_x = ctx_x(rotate_args[1], ctx)
3189 pivot_y = ctx_y(rotate_args[2], ctx)
3190 cx_box = box_x + box_w / 2
3191 cy_box = box_y + box_h / 2
3192 rad = math.radians(angle_deg)
3193 dx = cx_box - pivot_x
3194 dy = cy_box - pivot_y
3195 new_cx = pivot_x + dx * math.cos(rad) - dy * math.sin(rad)
3196 new_cy = pivot_y + dx * math.sin(rad) + dy * math.cos(rad)
3197 box_x = new_cx - box_w / 2
3198 box_y = new_cy - box_h / 2
3199
3200 # Alignment
3201 algn_map = {'start': 'l', 'middle': 'ctr', 'end': 'r'}
3202 algn = algn_map.get(text_anchor, 'l')
3203
3204 # Shadow effect
3205 shape_effect_xml = ''
3206 text_effect_xml = ''
3207 filt_id = get_effective_filter_id(elem, ctx)
3208 if filt_id and filt_id in ctx.defs:
3209 filter_elem = ctx.defs[filt_id]
3210 effect_kind = classify_filter_effect(filter_elem)
3211 if effect_kind == 'glow':
3212 text_effect_xml = build_effect_xml(
3213 filter_elem,
3214 get_element_opacity(elem, ctx),
3215 )
3216 elif effect_kind == 'shadow':
3217 shape_effect_xml = build_effect_xml(
3218 filter_elem,
3219 get_element_opacity(elem, ctx),
3220 )
3221
3222 shape_id = _claim_element_shape_id(elem, ctx)
3223 rot_attr = f' rot="{text_rot}"' if text_rot else ''
3224
3225 if paragraph_runs is not None:
3226 # SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
3227 line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
3228 ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
3229 paragraph_xml_chunks = []
3230 for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
3231 spc_bef_xml = ''
3232 if extra_px > 0:
3233 spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
3234 spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
3235 runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
3236 first_text_run = next(
3237 (run for run in line if not run.get('_line_break')),
3238 None,
3239 )
3240 effective_line_spacing = (
3241 ''
3242 if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line)
3243 else ln_spc_xml
3244 )
3245 p_pr_xml = _paragraph_pr_xml(
3246 algn=algn,
3247 font_size=(
3248 float(first_text_run.get('font_size', font_size))
3249 if first_text_run is not None
3250 else font_size
3251 ),
3252 body_xml=f'{effective_line_spacing}{spc_bef_xml}',
3253 bullet=bullet,
3254 ctx=ctx,
3255 rtl=text_uses_rtl(
3256 ''.join(str(run.get('text', '')) for run in line),
3257 ctx.primary_language,
3258 ),
3259 )
3260 paragraph_xml_chunks.append(
3261 f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
3262 )
3263 paragraphs_xml = '\n'.join(paragraph_xml_chunks)
3264 else:
3265 runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs)
3266 p_pr_xml = _paragraph_pr_xml(
3267 algn=algn,
3268 font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
3269 bullet=single_bullet,
3270 ctx=ctx,
3271 rtl=text_uses_rtl(full_text, ctx.primary_language),
3272 )
3273 paragraphs_xml = f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
3274
3275 off_x = px_to_emu(box_x)
3276 off_y = px_to_emu(box_y)
3277 ext_cx = (
3278 px_to_emu(box_w)
3279 if exact_text_frame is not None
3280 else drawingml_text_frame_width_emu(text_width, font_size)
3281 )
3282 ext_cy = px_to_emu(box_h)
3283 if ext_cx < 1 or ext_cy < 1:
3284 raise ValueError(
3285 'negative letter-spacing produces a non-positive DrawingML '
3286 f'text-frame extent (cx={ext_cx}, cy={ext_cy})'
3287 )
3288 validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy)
3289 validate_text_run_advances(runs)
3290
3291 # Imported text carriers with data-pptx-frame retain the source shape frame
3292 # instead of shrinking to glyph bounds. Reconstruct insets from the SVG
3293 # anchor/baseline so the visible text stays at its imported position while
3294 # remaining ordinary editable DrawingML text.
3295 if exact_text_frame is not None:
3296 if exact_text_insets is None:
3297 raise ValueError('data-pptx-frame text insets were not resolved')
3298 left_inset, top_inset, right_inset = exact_text_insets
3299 exact_frame_wrap = (
3300 'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square'
3301 )
3302 body_pr_xml = (
3303 f'<a:bodyPr wrap="{exact_frame_wrap}" '
3304 f'lIns="{px_to_emu(left_inset)}" '
3305 f'tIns="{px_to_emu(top_inset)}" '
3306 f'rIns="{px_to_emu(right_inset)}" bIns="0" '
3307 'anchor="t" anchorCtr="0">\n<a:noAutofit/>\n</a:bodyPr>'
3308 )
3309 # Preserve mode keeps authored <a:br/> boundaries and lets an ordinary
3310 # generated text box follow later manual edits, such as deleting a break.
3311 # Reflow mode keeps the source width fixed as its wrapping constraint.
3312 # Exact imported frames above and structured placeholder carriers remain
3313 # fixed regardless of text-flow mode.
3314 elif paragraph_runs is not None:
3315 paragraph_wrap = (
3316 'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square'
3317 )
3318 paragraph_autofit = (
3319 '<a:spAutoFit/>'
3320 if (
3321 ctx.text_flow == TEXT_FLOW_PRESERVE
3322 and not is_placeholder_carrier
3323 )
3324 else '<a:noAutofit/>'
3325 )
3326 body_pr_xml = (
3327 f'<a:bodyPr wrap="{paragraph_wrap}" '
3328 'lIns="0" tIns="0" rIns="0" bIns="0" '
3329 f'anchor="t" anchorCtr="0">\n{paragraph_autofit}\n</a:bodyPr>'
3330 )
3331 else:
3332 body_pr_xml = (
3333 '<a:bodyPr wrap="none" lIns="0" tIns="0" rIns="0" bIns="0" '
3334 'anchor="t" anchorCtr="0">\n<a:spAutoFit/>\n</a:bodyPr>'
3335 )
3336
3337 shape_xml = f'''<p:sp>
3338 <p:nvSpPr>
3339 <p:cNvPr id="{shape_id}" name="TextBox {shape_id}"/>
3340 <p:cNvSpPr txBox="1"/><p:nvPr/>
3341 </p:nvSpPr>
3342 <p:spPr>
3343 <a:xfrm{rot_attr}><a:off x="{off_x}" y="{off_y}"/>
3344 <a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
3345 <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
3346 <a:noFill/>
3347 <a:ln><a:noFill/></a:ln>
3348 {shape_effect_xml}
3349 </p:spPr>
3350 <p:txBody>
3351 {body_pr_xml}
3352 <a:lstStyle/>
3353 {paragraphs_xml}
3354 </p:txBody>
3355 </p:sp>'''
3356 if any(run.get(_INLINE_FORMULA_KEY) is not None for run in runs):
3357 from ..native_objects.inline_formula import wrap_inline_formula_shape
3358 shape_xml = wrap_inline_formula_shape(shape_xml)
3359 return ShapeResult(
3360 xml=shape_xml,
3361 bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
3362 )
3363
3364
3365 # ---------------------------------------------------------------------------
3366 # clipPath support (image clipping)
3367 # ---------------------------------------------------------------------------
3368
3369 def _clip_commands_to_geom(
3370 commands: list[PathCommand],
3371 img_x: float, img_y: float,
3372 img_w: float, img_h: float,
3373 object_bbox: bool,
3374 ) -> str:
3375 """Convert clip path commands to DrawingML custGeom XML.
3376
3377 Coordinates are transformed relative to the image bounding box so that
3378 (img_x, img_y) maps to (0, 0) and (img_x+img_w, img_y+img_h) maps to
3379 (w_emu, h_emu).
3380 """
3381 w_emu = px_to_emu(img_w)
3382 h_emu = px_to_emu(img_h)
3383
3384 if w_emu <= 0 or h_emu <= 0:
3385 return '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
3386
3387 def _tx(x: float) -> int:
3388 if object_bbox:
3389 return int(x * w_emu)
3390 return px_to_emu(x - img_x)
3391
3392 def _ty(y: float) -> int:
3393 if object_bbox:
3394 return int(y * h_emu)
3395 return px_to_emu(y - img_y)
3396
3397 parts: list[str] = []
3398 for cmd in commands:
3399 if cmd.cmd == 'M':
3400 parts.append(
3401 f'<a:moveTo><a:pt x="{_tx(cmd.args[0])}" '
3402 f'y="{_ty(cmd.args[1])}"/></a:moveTo>'
3403 )
3404 elif cmd.cmd == 'L':
3405 parts.append(
3406 f'<a:lnTo><a:pt x="{_tx(cmd.args[0])}" '
3407 f'y="{_ty(cmd.args[1])}"/></a:lnTo>'
3408 )
3409 elif cmd.cmd == 'C':
3410 pts = ''.join(
3411 f'<a:pt x="{_tx(cmd.args[i])}" y="{_ty(cmd.args[i + 1])}"/>'
3412 for i in range(0, 6, 2)
3413 )
3414 parts.append(f'<a:cubicBezTo>{pts}</a:cubicBezTo>')
3415 elif cmd.cmd == 'Z':
3416 parts.append('<a:close/>')
3417
3418 path_inner = '\n'.join(parts)
3419 return f'''<a:custGeom>
3420 <a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
3421 <a:rect l="l" t="t" r="r" b="b"/>
3422 <a:pathLst><a:path w="{w_emu}" h="{h_emu}">
3423 {path_inner}
3424 </a:path></a:pathLst>
3425 </a:custGeom>'''
3426
3427
3428 _CLIP_SHAPE_TAGS = frozenset({'circle', 'ellipse', 'rect', 'path', 'polygon'})
3429 _CLIP_NON_VISUAL_ELEMENTS = frozenset({
3430 f'{{{SVG_NS}}}{tag}' for tag in ('desc', 'metadata', 'style', 'title')
3431 })
3432
3433
3434 def _element_contract_label(elem: ET.Element) -> str:
3435 tag = elem.tag.rsplit('}', 1)[-1]
3436 elem_id = (elem.get('id') or '').strip()
3437 return f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
3438
3439
3440 def _unsupported_clip_rule_properties(elem: ET.Element) -> tuple[str, ...]:
3441 style_values = parse_inline_style(elem.get('style'))
3442 return tuple(
3443 name for name in ('clip-rule', 'fill-rule')
3444 if elem.get(name) is not None or name in style_values
3445 )
3446
3447
3448 def _effective_clip_geometry_length(
3449 elem: ET.Element,
3450 attribute: str,
3451 *,
3452 default: float | None = None,
3453 ) -> float:
3454 style_values = parse_inline_style(elem.get('style'))
3455 raw = style_values.get(attribute)
3456 if raw is None:
3457 raw = elem.get(attribute)
3458 if raw is None:
3459 if default is None:
3460 raise ValueError(f'requires {attribute}')
3461 return default
3462 return parse_project_geometry_length(raw, attribute)
3463
3464
3465 def _clip_preset_geometry_error(
3466 target: ET.Element,
3467 shape: ET.Element,
3468 clip_units: str,
3469 ) -> str | None:
3470 """Reject primitive clips that cannot map to a full-frame preset."""
3471 shape_tag = shape.tag.rsplit('}', 1)[-1].lower()
3472 if shape_tag not in {'circle', 'ellipse', 'rect'}:
3473 return None
3474 target_label = _element_contract_label(target)
3475 try:
3476 target_x = _effective_clip_geometry_length(target, 'x', default=0.0)
3477 target_y = _effective_clip_geometry_length(target, 'y', default=0.0)
3478 target_w = _effective_clip_geometry_length(target, 'width')
3479 target_h = _effective_clip_geometry_length(target, 'height')
3480 except ValueError as exc:
3481 return f'cannot validate {shape_tag} against {target_label}: {exc}'
3482 if target_w <= 0 or target_h <= 0:
3483 return (
3484 f'cannot validate {shape_tag} against {target_label}: target '
3485 'width and height must be positive'
3486 )
3487
3488 object_bbox = clip_units == 'objectBoundingBox'
3489 expected_x = 0.0 if object_bbox else target_x
3490 expected_y = 0.0 if object_bbox else target_y
3491 expected_w = 1.0 if object_bbox else target_w
3492 expected_h = 1.0 if object_bbox else target_h
3493
3494 def close(actual: float, expected: float) -> bool:
3495 return math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-6)
3496
3497 try:
3498 if shape_tag == 'circle':
3499 cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
3500 cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
3501 radius = _effective_clip_geometry_length(shape, 'r', default=0.0)
3502 fits = (
3503 close(expected_w, expected_h)
3504 and close(cx, expected_x + expected_w / 2.0)
3505 and close(cy, expected_y + expected_h / 2.0)
3506 and close(radius, expected_w / 2.0)
3507 )
3508 elif shape_tag == 'ellipse':
3509 cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
3510 cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
3511 rx = _effective_clip_geometry_length(shape, 'rx', default=0.0)
3512 ry = _effective_clip_geometry_length(shape, 'ry', default=0.0)
3513 fits = (
3514 close(cx, expected_x + expected_w / 2.0)
3515 and close(cy, expected_y + expected_h / 2.0)
3516 and close(rx, expected_w / 2.0)
3517 and close(ry, expected_h / 2.0)
3518 )
3519 else:
3520 rect_x = _effective_clip_geometry_length(shape, 'x', default=0.0)
3521 rect_y = _effective_clip_geometry_length(shape, 'y', default=0.0)
3522 rect_w = _effective_clip_geometry_length(shape, 'width', default=0.0)
3523 rect_h = _effective_clip_geometry_length(shape, 'height', default=0.0)
3524 fits = (
3525 close(rect_x, expected_x)
3526 and close(rect_y, expected_y)
3527 and close(rect_w, expected_w)
3528 and close(rect_h, expected_h)
3529 )
3530 if fits:
3531 rx_raw = (
3532 parse_inline_style(shape.get('style')).get('rx')
3533 or shape.get('rx')
3534 )
3535 ry_raw = (
3536 parse_inline_style(shape.get('style')).get('ry')
3537 or shape.get('ry')
3538 )
3539 rx = (
3540 parse_project_geometry_length(rx_raw, 'rx')
3541 if rx_raw is not None else None
3542 )
3543 ry = (
3544 parse_project_geometry_length(ry_raw, 'ry')
3545 if ry_raw is not None else None
3546 )
3547 if rx is None and ry is not None:
3548 rx = ry
3549 elif ry is None and rx is not None:
3550 ry = rx
3551 rx = rx or 0.0
3552 ry = ry or 0.0
3553 if rx > 0 or ry > 0:
3554 if object_bbox:
3555 fits = close(rx * target_w, ry * target_h)
3556 else:
3557 fits = close(rx, ry)
3558 except ValueError as exc:
3559 return f'{shape_tag} geometry for {target_label} is invalid: {exc}'
3560
3561 if fits:
3562 return None
3563 return (
3564 f'{shape_tag} geometry must cover the complete frame of {target_label} '
3565 'for native preset mapping; use path or polygon for partial, offset, '
3566 'or non-uniform clips'
3567 )
3568
3569
3570 def _nested_crop_clip_preset_geometry_error(
3571 wrapper: ET.Element,
3572 shape: ET.Element,
3573 clip_units: str,
3574 ) -> str | None:
3575 """Validate an inner-image clip against the crop wrapper's viewBox."""
3576 if clip_units != 'userSpaceOnUse':
3577 return (
3578 'inner <image> clip on a nested crop must use '
3579 'clipPathUnits="userSpaceOnUse" so browser and PowerPoint '
3580 'evaluate the visible viewBox region identically'
3581 )
3582 try:
3583 crop = parse_project_nested_svg_crop(wrapper)
3584 except ValueError as exc:
3585 return f'cannot validate nested crop geometry: {exc}'
3586
3587 shape_tag = shape.tag.rsplit('}', 1)[-1].lower()
3588 if shape_tag not in {'circle', 'ellipse', 'rect'}:
3589 return None
3590 expected_x = crop.view_box_x
3591 expected_y = crop.view_box_y
3592 expected_w = crop.view_box_width
3593 expected_h = crop.view_box_height
3594
3595 def close(actual: float, expected: float) -> bool:
3596 return math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-6)
3597
3598 try:
3599 if shape_tag == 'circle':
3600 cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
3601 cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
3602 radius = _effective_clip_geometry_length(shape, 'r', default=0.0)
3603 fits = (
3604 close(expected_w, expected_h)
3605 and close(cx, expected_x + expected_w / 2.0)
3606 and close(cy, expected_y + expected_h / 2.0)
3607 and close(radius, expected_w / 2.0)
3608 )
3609 elif shape_tag == 'ellipse':
3610 cx = _effective_clip_geometry_length(shape, 'cx', default=0.0)
3611 cy = _effective_clip_geometry_length(shape, 'cy', default=0.0)
3612 rx = _effective_clip_geometry_length(shape, 'rx', default=0.0)
3613 ry = _effective_clip_geometry_length(shape, 'ry', default=0.0)
3614 fits = (
3615 close(cx, expected_x + expected_w / 2.0)
3616 and close(cy, expected_y + expected_h / 2.0)
3617 and close(rx, expected_w / 2.0)
3618 and close(ry, expected_h / 2.0)
3619 )
3620 else:
3621 rect_x = _effective_clip_geometry_length(shape, 'x', default=0.0)
3622 rect_y = _effective_clip_geometry_length(shape, 'y', default=0.0)
3623 rect_w = _effective_clip_geometry_length(shape, 'width', default=0.0)
3624 rect_h = _effective_clip_geometry_length(shape, 'height', default=0.0)
3625 fits = (
3626 close(rect_x, expected_x)
3627 and close(rect_y, expected_y)
3628 and close(rect_w, expected_w)
3629 and close(rect_h, expected_h)
3630 )
3631 if fits:
3632 rx_raw = (
3633 parse_inline_style(shape.get('style')).get('rx')
3634 or shape.get('rx')
3635 )
3636 ry_raw = (
3637 parse_inline_style(shape.get('style')).get('ry')
3638 or shape.get('ry')
3639 )
3640 rx = (
3641 parse_project_geometry_length(rx_raw, 'rx')
3642 if rx_raw is not None else None
3643 )
3644 ry = (
3645 parse_project_geometry_length(ry_raw, 'ry')
3646 if ry_raw is not None else None
3647 )
3648 if rx is None and ry is not None:
3649 rx = ry
3650 elif ry is None and rx is not None:
3651 ry = rx
3652 rx = rx or 0.0
3653 ry = ry or 0.0
3654 if rx > 0 or ry > 0:
3655 physical_rx = rx * crop.width / expected_w
3656 physical_ry = ry * crop.height / expected_h
3657 fits = math.isclose(
3658 physical_rx,
3659 physical_ry,
3660 rel_tol=1e-9,
3661 abs_tol=1e-3,
3662 )
3663 except ValueError as exc:
3664 return f'{shape_tag} geometry for nested crop is invalid: {exc}'
3665
3666 if fits:
3667 return None
3668 return (
3669 f'{shape_tag} geometry must cover the nested crop viewBox and use '
3670 'equal physical corner radii after viewport scaling'
3671 )
3672
3673
3674 def project_clip_path_errors(root: ET.Element) -> list[str]:
3675 """Return clip-path errors that would otherwise degrade picture geometry."""
3676 definitions, duplicates = project_definition_index(root)
3677 parent_by_id = {
3678 id(child): parent
3679 for parent in root.iter()
3680 for child in list(parent)
3681 }
3682 errors: set[str] = set()
3683 for elem in root.iter():
3684 raw_ref = elem.get('clip-path')
3685 if raw_ref is None or raw_ref.strip().lower() == 'none':
3686 continue
3687 label = _element_contract_label(elem)
3688 is_svg_image = elem.tag == f'{{{SVG_NS}}}image'
3689 parent = parent_by_id.get(id(elem))
3690 is_nested_crop_image = (
3691 is_svg_image
3692 and parent is not None
3693 and parent.tag == f'{{{SVG_NS}}}svg'
3694 and parent is not root
3695 and parent.get('data-pptx-crop') == '1'
3696 )
3697 is_imported_crop = (
3698 elem.tag == f'{{{SVG_NS}}}svg'
3699 and elem.get('data-pptx-crop') == '1'
3700 )
3701 if not is_svg_image and not is_imported_crop:
3702 errors.add(
3703 f'{label} clip-path is allowed only on <image> or an imported '
3704 'data-pptx-crop="1" wrapper'
3705 )
3706 match = re.fullmatch(r'url\(#([^)]+)\)', raw_ref.strip())
3707 if match is None:
3708 errors.add(
3709 f'{label} clip-path must be an exact local url(#id) '
3710 f'reference; got {raw_ref!r}'
3711 )
3712 continue
3713 clip_id = match.group(1)
3714 if clip_id in duplicates:
3715 errors.add(
3716 f'{label} clip-path=url(#{clip_id}) is ambiguous because the '
3717 'definition id is duplicated'
3718 )
3719 continue
3720 clip = definitions.get(clip_id)
3721 if clip is None or clip.tag != f'{{{SVG_NS}}}clipPath':
3722 errors.add(
3723 f'{label} clip-path=url(#{clip_id}) has no matching direct '
3724 f'<defs><clipPath id="{clip_id}"> definition'
3725 )
3726 continue
3727 clip_label = f'<clipPath id="{clip_id}">'
3728 clip_units = clip.get('clipPathUnits', 'userSpaceOnUse')
3729 if clip_units not in {'userSpaceOnUse', 'objectBoundingBox'}:
3730 errors.add(
3731 f'{clip_label} has unsupported clipPathUnits={clip_units!r}'
3732 )
3733 if clip.get('transform'):
3734 errors.add(f'{clip_label} cannot use transform')
3735 clip_rules = _unsupported_clip_rule_properties(clip)
3736 if clip_rules:
3737 errors.add(
3738 f'{clip_label} cannot use {", ".join(clip_rules)}; native '
3739 'picture geometry has no equivalent winding-rule control'
3740 )
3741 visual_children = [
3742 child for child in list(clip)
3743 if child.tag not in _CLIP_NON_VISUAL_ELEMENTS
3744 ]
3745 if len(visual_children) != 1:
3746 errors.add(
3747 f'{clip_label} must contain exactly one direct supported shape'
3748 )
3749 continue
3750 shape = visual_children[0]
3751 shape_tag = shape.tag.rsplit('}', 1)[-1].lower()
3752 if (
3753 shape_tag not in _CLIP_SHAPE_TAGS
3754 or shape.tag != f'{{{SVG_NS}}}{shape_tag}'
3755 ):
3756 errors.add(
3757 f'{clip_label} child <{shape_tag}> is unsupported; use '
3758 'circle, ellipse, rect, path, or polygon'
3759 )
3760 continue
3761 if shape.get('transform'):
3762 errors.add(f'{clip_label} child <{shape_tag}> cannot use transform')
3763 continue
3764 shape_rules = _unsupported_clip_rule_properties(shape)
3765 if shape_rules:
3766 errors.add(
3767 f'{clip_label} child <{shape_tag}> cannot use '
3768 f'{", ".join(shape_rules)}; native picture geometry has no '
3769 'equivalent winding-rule control'
3770 )
3771 continue
3772 if clip_units in {'userSpaceOnUse', 'objectBoundingBox'}:
3773 if is_nested_crop_image:
3774 geometry_error = _nested_crop_clip_preset_geometry_error(
3775 parent,
3776 shape,
3777 clip_units,
3778 )
3779 else:
3780 geometry_error = _clip_preset_geometry_error(
3781 elem,
3782 shape,
3783 clip_units,
3784 )
3785 if geometry_error is not None:
3786 errors.add(f'{clip_label} {geometry_error}')
3787 return sorted(errors)
3788
3789
3790 def _resolve_clip_geometry(
3791 elem: ET.Element,
3792 ctx: ConvertContext,
3793 raw_x: float, raw_y: float,
3794 raw_w: float, raw_h: float,
3795 ) -> str:
3796 """Resolve clip-path on an image element to DrawingML geometry XML.
3797
3798 Supports:
3799 - circle / ellipse → prstGeom ellipse
3800 - rect with rx/ry → prstGeom roundRect
3801 - path / polygon → custGeom
3802
3803 Args:
3804 elem: SVG element bearing a clip-path attribute.
3805 ctx: Conversion context (carries defs).
3806 raw_x, raw_y: Image position in SVG space (pre-ctx-transform).
3807 raw_w, raw_h: Image dimensions in SVG space (pre-ctx-transform).
3808
3809 Returns:
3810 DrawingML geometry XML string.
3811 """
3812 DEFAULT = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
3813
3814 clip_ref = elem.get('clip-path', '')
3815 if not clip_ref or clip_ref == 'none':
3816 return DEFAULT
3817
3818 clip_id = resolve_url_id(clip_ref)
3819 if not clip_id or clip_id not in ctx.defs:
3820 return DEFAULT
3821
3822 clip_elem = ctx.defs[clip_id]
3823 clip_tag = clip_elem.tag.replace(f'{{{SVG_NS}}}', '')
3824 if clip_tag != 'clipPath':
3825 return DEFAULT
3826
3827 # Find the first shape child of the clipPath
3828 shape = None
3829 for child in clip_elem:
3830 child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
3831 if child_tag in ('circle', 'ellipse', 'rect', 'path', 'polygon'):
3832 shape = child
3833 break
3834
3835 if shape is None:
3836 return DEFAULT
3837
3838 shape_tag = shape.tag.replace(f'{{{SVG_NS}}}', '')
3839 is_obb = clip_elem.get('clipPathUnits') == 'objectBoundingBox'
3840
3841 # --- Circle / Ellipse → preset ellipse ---
3842 if shape_tag in ('circle', 'ellipse'):
3843 return '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
3844
3845 # --- Rect with rx/ry → preset roundRect ---
3846 if shape_tag == 'rect':
3847 rx_attr = shape.get('rx')
3848 ry_attr = shape.get('ry')
3849 rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
3850 ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else rx
3851 if rx <= 0 and ry <= 0:
3852 return DEFAULT # plain rect clip is a no-op
3853 r = max(rx, ry)
3854 if is_obb:
3855 r = r * min(raw_w, raw_h)
3856 shorter = min(raw_w, raw_h)
3857 if shorter <= 0:
3858 return DEFAULT
3859 adj = int(min(r / (shorter / 2), 1.0) * 50000)
3860 return (
3861 f'<a:prstGeom prst="roundRect"><a:avLst>'
3862 f'<a:gd name="adj" fmla="val {adj}"/>'
3863 f'</a:avLst></a:prstGeom>'
3864 )
3865
3866 # --- Path → custGeom ---
3867 if shape_tag == 'path':
3868 d = shape.get('d', '')
3869 if not d:
3870 return DEFAULT
3871 commands = parse_svg_path(d)
3872 commands = svg_path_to_absolute(commands)
3873 commands = normalize_path_commands(commands)
3874 if not commands:
3875 return DEFAULT
3876 return _clip_commands_to_geom(
3877 commands, raw_x, raw_y, raw_w, raw_h, is_obb,
3878 )
3879
3880 # --- Polygon → custGeom ---
3881 if shape_tag == 'polygon':
3882 pts = parse_svg_points(shape.get('points', ''), min_points=3)
3883 commands = [PathCommand('M', [pts[0][0], pts[0][1]])]
3884 for px_, py_ in pts[1:]:
3885 commands.append(PathCommand('L', [px_, py_]))
3886 commands.append(PathCommand('Z', []))
3887 return _clip_commands_to_geom(
3888 commands, raw_x, raw_y, raw_w, raw_h, is_obb,
3889 )
3890
3891 return DEFAULT
3892
3893
3894 # ---------------------------------------------------------------------------
3895 # image
3896 # ---------------------------------------------------------------------------
3897
3898 def _picture_xfrm_from_rect(
3899 ctx: ConvertContext,
3900 x: float,
3901 y: float,
3902 w: float,
3903 h: float,
3904 ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
3905 """Build DrawingML xfrm data for a picture rectangle.
3906
3907 Coordinates ``x``, ``y``, ``w``, ``h`` MUST already be in ctx-resolved
3908 space (i.e. callers have applied ``ctx_x`` / ``ctx_w`` upstream). When
3909 ``ctx.use_transform_matrix`` is set, raw SVG-space coordinates are
3910 expected and the matrix path applies the transform itself.
3911 """
3912 if ctx.use_transform_matrix:
3913 return rect_to_dml_xfrm(x, y, w, h, ctx.transform_matrix)
3914
3915 off_x = px_to_emu(x)
3916 off_y = px_to_emu(y)
3917 ext_cx = px_to_emu(w)
3918 ext_cy = px_to_emu(h)
3919 return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
3920
3921
3922 def _picture_xfrm_from_svg_rect(
3923 ctx: ConvertContext,
3924 raw_x: float,
3925 raw_y: float,
3926 raw_w: float,
3927 raw_h: float,
3928 resolved_x: float,
3929 resolved_y: float,
3930 resolved_w: float,
3931 resolved_h: float,
3932 transform: str | None,
3933 ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
3934 """Build picture xfrm data, honoring element-level SVG transforms.
3935
3936 ``raw_*`` values stay in the element's source SVG coordinate space for
3937 matrix decomposition; ``resolved_*`` values are the existing scalar path.
3938 """
3939 if ctx.use_transform_matrix:
3940 matrix = ctx.transform_matrix
3941 if transform:
3942 matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
3943 return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix)
3944
3945 if transform:
3946 context_matrix = (
3947 ctx.scale_x, 0.0,
3948 0.0, ctx.scale_y,
3949 ctx.translate_x, ctx.translate_y,
3950 )
3951 matrix = matrix_multiply(context_matrix, parse_transform_matrix(transform))
3952 return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix)
3953
3954 return _picture_xfrm_from_rect(ctx, resolved_x, resolved_y, resolved_w, resolved_h)
3955
3956
3957 def _picture_rendered_frame_size(
3958 ctx: ConvertContext,
3959 raw_x: float,
3960 raw_y: float,
3961 raw_w: float,
3962 raw_h: float,
3963 resolved_x: float,
3964 resolved_y: float,
3965 resolved_w: float,
3966 resolved_h: float,
3967 transform: str | None,
3968 ) -> tuple[float, float]:
3969 """Return final DrawingML picture-axis lengths in rendered SVG pixels."""
3970 _attr, _x, _y, ext_cx, ext_cy, _bounds = _picture_xfrm_from_svg_rect(
3971 ctx,
3972 raw_x,
3973 raw_y,
3974 raw_w,
3975 raw_h,
3976 resolved_x,
3977 resolved_y,
3978 resolved_w,
3979 resolved_h,
3980 transform,
3981 )
3982 emu_per_px = px_to_emu(1.0)
3983 return ext_cx / emu_per_px, ext_cy / emu_per_px
3984
3985
3986 def _read_svg_image_size(data: bytes) -> tuple[float, float] | None:
3987 """Read an SVG image's viewport ratio from root dimensions or viewBox."""
3988 try:
3989 root = ET.fromstring(data)
3990 except ET.ParseError:
3991 return None
3992 if root.tag != f'{{{SVG_NS}}}svg':
3993 return None
3994
3995 try:
3996 width = parse_svg_length(root.get('width'))
3997 height = parse_svg_length(root.get('height'))
3998 except ValueError:
3999 width = 0.0
4000 height = 0.0
4001 if (
4002 width > 0
4003 and height > 0
4004 and math.isfinite(width)
4005 and math.isfinite(height)
4006 ):
4007 return width, height
4008
4009 view_box = root.get('viewBox')
4010 if view_box:
4011 parts = [
4012 part for part in re.split(r'[\s,]+', view_box.strip())
4013 if part
4014 ]
4015 try:
4016 values = [float(part) for part in parts]
4017 except ValueError:
4018 values = []
4019 if (
4020 len(values) == 4
4021 and all(math.isfinite(value) for value in values)
4022 and values[2] > 0
4023 and values[3] > 0
4024 ):
4025 return values[2], values[3]
4026 return None
4027
4028
4029 def _read_image_size(data: bytes) -> tuple[float | None, float | None]:
4030 """Read intrinsic image dimensions (width, height) from raw bytes.
4031
4032 Used by ``convert_image`` to translate SVG ``preserveAspectRatio`` into
4033 DrawingML ``<a:srcRect>`` so the original image is preserved and remains
4034 croppable inside PowerPoint.
4035
4036 SVG images use valid root ``width`` / ``height`` as the viewport ratio and
4037 fall back to ``viewBox`` when either dimension is unavailable. Raster
4038 images use EXIF-normalized pixel dimensions. Returns ``(None, None)`` on
4039 any failure.
4040 """
4041 svg_size = _read_svg_image_size(data)
4042 if svg_size is not None:
4043 return svg_size
4044
4045 try:
4046 from PIL import Image, ImageOps, UnidentifiedImageError # type: ignore
4047 except ImportError:
4048 return (None, None)
4049 try:
4050 with Image.open(io.BytesIO(data)) as img:
4051 oriented = ImageOps.exif_transpose(img)
4052 try:
4053 return oriented.size
4054 finally:
4055 if oriented is not img:
4056 oriented.close()
4057 except (
4058 UnidentifiedImageError,
4059 OSError,
4060 SyntaxError,
4061 ValueError,
4062 ZeroDivisionError,
4063 ):
4064 return (None, None)
4065
4066
4067 def _image_has_alpha(img: Any) -> bool:
4068 """Return whether a PIL image carries useful transparency."""
4069 if img.mode in ('RGBA', 'LA'):
4070 return True
4071 return 'transparency' in getattr(img, 'info', {})
4072
4073
4074 def _prepare_raster_for_geometry(img: Any) -> Any:
4075 """Apply EXIF orientation and materialize palette/tRNS transparency."""
4076 try:
4077 from PIL import ImageOps # type: ignore
4078 except ImportError:
4079 return img
4080
4081 prepared = ImageOps.exif_transpose(img)
4082 if prepared.mode == 'P':
4083 prepared = prepared.convert(
4084 'RGBA' if _image_has_alpha(prepared) else 'RGB'
4085 )
4086 elif (
4087 'transparency' in getattr(prepared, 'info', {})
4088 and prepared.mode not in {'RGBA', 'LA'}
4089 ):
4090 prepared = prepared.convert('RGBA')
4091 return prepared
4092
4093
4094 def _has_exif_geometry_transform(img: Any) -> bool:
4095 """Return whether EXIF requires a physical mirror or rotation."""
4096 try:
4097 return int(img.getexif().get(274, 1)) in range(2, 9)
4098 except (AttributeError, TypeError, ValueError):
4099 return False
4100
4101
4102 def _image_target_size(
4103 display_w: float,
4104 display_h: float,
4105 *,
4106 max_dimension: int | None,
4107 scale: float,
4108 ) -> tuple[int, int]:
4109 """Resolve optimized pixel dimensions from rendered SVG dimensions."""
4110 target_w = max(1, int(round(display_w * max(scale, 1.0))))
4111 target_h = max(1, int(round(display_h * max(scale, 1.0))))
4112 if max_dimension and max(target_w, target_h) > max_dimension:
4113 ratio = max_dimension / max(target_w, target_h)
4114 target_w = max(1, int(round(target_w * ratio)))
4115 target_h = max(1, int(round(target_h * ratio)))
4116 return target_w, target_h
4117
4118
4119 def _visible_source_scale_floor(
4120 img_w: int,
4121 img_h: int,
4122 display_w: float,
4123 display_h: float,
4124 visible_source_fraction: tuple[float, float],
4125 ) -> float:
4126 """Keep each rendered axis at or below its visible source pixels."""
4127 fraction_w, fraction_h = visible_source_fraction
4128 visible_w = img_w * fraction_w
4129 visible_h = img_h * fraction_h
4130 if visible_w <= 0 or visible_h <= 0:
4131 return 1.0
4132 return min(1.0, max(display_w / visible_w, display_h / visible_h))
4133
4134
4135 def _fit_full_image_target(
4136 img_w: int,
4137 img_h: int,
4138 display_w: float,
4139 display_h: float,
4140 *,
4141 sizing: str,
4142 max_dimension: int | None,
4143 scale: float,
4144 visible_source_fraction: tuple[float, float] | None = None,
4145 ) -> tuple[int, int]:
4146 """Size the full source image; never crop pixels.
4147
4148 ``cap`` mode limits oversized sources unless that would leave a cropped
4149 or stretched picture with fewer visible source pixels than its final
4150 rendered frame. ``display`` mode targets the configured rendered-frame
4151 scale while retaining the same one-source-pixel-per-display-pixel floor.
4152 """
4153 if img_w <= 0 or img_h <= 0:
4154 return (1, 1)
4155
4156 enforce_visible_floor = visible_source_fraction is not None
4157 if enforce_visible_floor and (
4158 any(
4159 not math.isfinite(fraction) or fraction <= 0
4160 for fraction in visible_source_fraction
4161 )
4162 ):
4163 return (img_w, img_h)
4164
4165 if sizing == 'cap':
4166 ratio = 1.0
4167 if max_dimension and max(img_w, img_h) > max_dimension:
4168 ratio = max_dimension / max(img_w, img_h)
4169 if enforce_visible_floor:
4170 ratio = max(
4171 ratio,
4172 _visible_source_scale_floor(
4173 img_w,
4174 img_h,
4175 display_w,
4176 display_h,
4177 visible_source_fraction,
4178 ),
4179 )
4180 ratio = min(1.0, ratio)
4181 target_w = max(1, int(math.ceil(img_w * ratio)))
4182 target_h = max(1, int(math.ceil(img_h * ratio)))
4183 return target_w, target_h
4184
4185 target_display_w, target_display_h = _image_target_size(
4186 display_w,
4187 display_h,
4188 max_dimension=None,
4189 scale=scale,
4190 )
4191
4192 if enforce_visible_floor:
4193 fraction_w, fraction_h = visible_source_fraction
4194 preferred_scale = min(
4195 1.0,
4196 max(
4197 target_display_w / (img_w * fraction_w),
4198 target_display_h / (img_h * fraction_h),
4199 ),
4200 )
4201 visible_floor = _visible_source_scale_floor(
4202 img_w,
4203 img_h,
4204 display_w,
4205 display_h,
4206 visible_source_fraction,
4207 )
4208 resize_scale = preferred_scale
4209 if max_dimension and max(img_w, img_h) * resize_scale > max_dimension:
4210 resize_scale = max(
4211 visible_floor,
4212 max_dimension / max(img_w, img_h),
4213 )
4214 target_w = int(math.ceil(img_w * resize_scale))
4215 target_h = int(math.ceil(img_h * resize_scale))
4216 else:
4217 ratio = min(
4218 target_display_w / img_w,
4219 target_display_h / img_h,
4220 1.0,
4221 )
4222 target_w = int(round(img_w * ratio))
4223 target_h = int(round(img_h * ratio))
4224
4225 target_w = max(1, target_w)
4226 target_h = max(1, target_h)
4227 if (
4228 not enforce_visible_floor
4229 and max_dimension
4230 and max(target_w, target_h) > max_dimension
4231 ):
4232 ratio = max_dimension / max(target_w, target_h)
4233 target_w = max(1, int(round(target_w * ratio)))
4234 target_h = max(1, int(round(target_h * ratio)))
4235 return target_w, target_h
4236
4237
4238 def _resize_for_target(img: Any, target_w: int, target_h: int) -> Any:
4239 """Downscale a PIL image to the target dimensions without upsampling."""
4240 width, height = img.size
4241 if target_w >= width and target_h >= height:
4242 return img
4243 ratio = min(target_w / width, target_h / height)
4244 if ratio >= 1.0:
4245 return img
4246 try:
4247 from PIL import Image # type: ignore
4248 except ImportError:
4249 return img
4250 new_size = (
4251 max(1, int(math.ceil(width * ratio))),
4252 max(1, int(math.ceil(height * ratio))),
4253 )
4254 return img.resize(new_size, Image.Resampling.LANCZOS)
4255
4256
4257 def _encode_optimized_image(img: Any, *, prefer_jpeg: bool, quality: int) -> tuple[bytes, str] | None:
4258 """Encode a PIL image for PPTX media."""
4259 buf = io.BytesIO()
4260 try:
4261 if prefer_jpeg and not _image_has_alpha(img):
4262 if img.mode != 'RGB':
4263 img = img.convert('RGB')
4264 img.save(buf, format='JPEG', quality=max(1, min(quality, 100)), optimize=True)
4265 return buf.getvalue(), 'jpg'
4266 if img.mode == 'P':
4267 img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB')
4268 elif img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}:
4269 img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB')
4270 img.save(buf, format='PNG', optimize=True)
4271 return buf.getvalue(), 'png'
4272 except (OSError, ValueError):
4273 return None
4274
4275
4276 def _optimize_image_for_pptx(
4277 ctx: ConvertContext,
4278 img_data: bytes,
4279 img_format: str,
4280 display_w: float,
4281 display_h: float,
4282 *,
4283 visible_source_fraction: tuple[float, float] | None = None,
4284 ) -> tuple[bytes, str]:
4285 """Optimize full raster image bytes for native PPTX embedding."""
4286 if not ctx.image_optimize:
4287 return img_data, img_format
4288 if img_format.lower() in {'svg', 'emf', 'wmf'}:
4289 return img_data, img_format
4290
4291 try:
4292 from PIL import Image, UnidentifiedImageError # type: ignore
4293 except ImportError:
4294 return img_data, img_format
4295
4296 try:
4297 img = Image.open(io.BytesIO(img_data))
4298 img.load()
4299 except (UnidentifiedImageError, OSError, ValueError):
4300 return img_data, img_format
4301
4302 # Multi-frame images (animated GIF / WebP / APNG): resize/re-encode
4303 # below keeps frame 0 only, flattening the animation in the exported
4304 # PPTX. Pass the original bytes through — animations are exempt from
4305 # optimization and the size cap (before this optimizer existed, the
4306 # native path embedded raster bytes verbatim and animations survived).
4307 if getattr(img, 'is_animated', False):
4308 return img_data, img_format
4309
4310 geometry_normalized = _has_exif_geometry_transform(img)
4311 img = _prepare_raster_for_geometry(img)
4312 target_w, target_h = _fit_full_image_target(
4313 img.size[0],
4314 img.size[1],
4315 display_w,
4316 display_h,
4317 sizing=ctx.image_sizing,
4318 max_dimension=ctx.image_max_dimension,
4319 scale=ctx.image_scale,
4320 visible_source_fraction=visible_source_fraction,
4321 )
4322
4323 original_size = img.size
4324 img = _resize_for_target(img, target_w, target_h)
4325 resized = img.size != original_size
4326 if (
4327 ctx.image_sizing == 'cap'
4328 and not geometry_normalized
4329 and not resized
4330 ):
4331 return img_data, img_format
4332 # Preserve source semantics: only an original JPEG stays lossy. PNG and
4333 # other static raster formats use lossless PNG after any resize.
4334 prefer_jpeg = img_format.lower() in {'jpg', 'jpeg'}
4335 encoded = _encode_optimized_image(img, prefer_jpeg=prefer_jpeg, quality=ctx.image_quality)
4336 if encoded is None:
4337 return img_data, img_format
4338
4339 optimized_data, optimized_format = encoded
4340 if (
4341 not geometry_normalized
4342 and not resized
4343 and len(optimized_data) >= len(img_data)
4344 ):
4345 return img_data, img_format
4346
4347 return optimized_data, optimized_format
4348
4349
4350 def _compute_slice_src_rect(
4351 img_w: float, img_h: float,
4352 box_w: float, box_h: float,
4353 align: str,
4354 ) -> tuple[int, int, int, int] | None:
4355 """Compute DrawingML ``<a:srcRect>`` (l, t, r, b) for SVG slice mode.
4356
4357 SVG ``preserveAspectRatio="<align> slice"`` means: scale the image so it
4358 fully covers the box (CSS object-fit: cover) and crop the overflow at the
4359 given alignment anchor. DrawingML ``srcRect`` expresses the same intent
4360 by specifying which sub-rectangle of the source image to display, in
4361 units of 1/1000 of a percent (0–100000).
4362
4363 Returns ``None`` when no cropping is required (image and box already
4364 match) or when inputs are degenerate.
4365 """
4366 if img_w <= 0 or img_h <= 0 or box_w <= 0 or box_h <= 0:
4367 return None
4368
4369 # Scale factor that makes the image cover the box (cover semantics).
4370 scale = max(box_w / img_w, box_h / img_h)
4371 visible_w = box_w / scale # ≤ img_w
4372 visible_h = box_h / scale # ≤ img_h
4373
4374 if (
4375 math.isclose(visible_w, img_w, rel_tol=1e-9, abs_tol=1e-9)
4376 and math.isclose(visible_h, img_h, rel_tol=1e-9, abs_tol=1e-9)
4377 ):
4378 return None # No crop needed
4379
4380 x_anchor, y_anchor = PROJECT_IMAGE_ASPECT_RATIO_ANCHORS[align]
4381 crop_w_total = max(
4382 0,
4383 min(99999, int(round((1.0 - visible_w / img_w) * 100000))),
4384 )
4385 crop_h_total = max(
4386 0,
4387 min(99999, int(round((1.0 - visible_h / img_h) * 100000))),
4388 )
4389 l = max(0, min(crop_w_total, int(round(crop_w_total * x_anchor))))
4390 t = max(0, min(crop_h_total, int(round(crop_h_total * y_anchor))))
4391 r = crop_w_total - l
4392 b = crop_h_total - t
4393 if not any((l, t, r, b)):
4394 return None
4395
4396 return (l, t, r, b)
4397
4398
4399 def _resolve_image_src_rect_values(
4400 elem: ET.Element,
4401 img_data: bytes,
4402 box_w: float, box_h: float,
4403 ) -> tuple[int, int, int, int] | None:
4404 """Resolve DrawingML source-crop values for an SVG slice image.
4405
4406 Slice mode is resolved into a srcRect so the original image is embedded
4407 intact and PowerPoint's crop tool / "Reset Picture" continue to work.
4408 Meet mode is handled separately by ``_resolve_image_meet_fit`` (which
4409 shrinks the picture frame to match image aspect ratio); none mode keeps
4410 the legacy stretch behaviour intentionally.
4411 """
4412 align, mode = parse_project_image_aspect_ratio(
4413 elem.get('preserveAspectRatio')
4414 )
4415
4416 if align == 'none' or mode != 'slice':
4417 return None
4418
4419 img_w, img_h = _read_image_size(img_data)
4420 if img_w is None or img_h is None:
4421 return None
4422
4423 return _compute_slice_src_rect(
4424 float(img_w),
4425 float(img_h),
4426 box_w,
4427 box_h,
4428 align,
4429 )
4430
4431
4432 def _src_rect_xml(rect: tuple[int, int, int, int] | None) -> str:
4433 """Serialize an optional DrawingML source crop."""
4434 if rect is None:
4435 return ''
4436 l, t, r, b = rect
4437 return f'<a:srcRect l="{l}" t="{t}" r="{r}" b="{b}"/>'
4438
4439
4440 def _resolve_image_meet_fit(
4441 elem: ET.Element,
4442 img_data: bytes,
4443 box_w: float, box_h: float,
4444 ) -> tuple[float, float, float, float] | None:
4445 """For SVG ``preserveAspectRatio="<align> meet"``, compute the letterboxed
4446 sub-rectangle ``(dx, dy, fit_w, fit_h)`` inside the original box that
4447 matches the image's intrinsic aspect ratio.
4448
4449 PowerPoint has no native ``meet`` semantic — ``<a:stretch><a:fillRect/>``
4450 fills the entire frame and would distort the image whenever the SVG
4451 container ratio differs from the source image ratio. The fix is to shrink
4452 the ``<p:pic>`` frame itself (off + ext) so the frame and image share an
4453 aspect ratio; the stretch then fills a correctly-shaped frame.
4454
4455 Returns ``None`` when the adjustment is not applicable:
4456 - mode is ``slice`` (handled by srcRect path)
4457 - align is ``none`` (SVG spec says: stretch — do not adjust)
4458 - intrinsic image dimensions cannot be read
4459 - frame already matches image ratio (no-op)
4460 """
4461 align, mode = parse_project_image_aspect_ratio(
4462 elem.get('preserveAspectRatio')
4463 )
4464
4465 if align == 'none' or mode == 'slice':
4466 return None
4467
4468 img_w, img_h = _read_image_size(img_data)
4469 if img_w is None or img_h is None or img_w <= 0 or img_h <= 0:
4470 return None
4471 if box_w <= 0 or box_h <= 0:
4472 return None
4473
4474 scale = min(box_w / img_w, box_h / img_h)
4475 fit_w = img_w * scale
4476 fit_h = img_h * scale
4477
4478 if abs(fit_w - box_w) < 0.5 and abs(fit_h - box_h) < 0.5:
4479 return None # already matches — no adjustment
4480
4481 x_anchor, y_anchor = PROJECT_IMAGE_ASPECT_RATIO_ANCHORS[align]
4482
4483 dx = (box_w - fit_w) * x_anchor
4484 dy = (box_h - fit_h) * y_anchor
4485
4486 return (dx, dy, fit_w, fit_h)
4487
4488
4489 def _build_image_blip_xml(r_id: str, opacity: float | None) -> str:
4490 """Build an image blip with native DrawingML transparency when requested."""
4491 if opacity is None:
4492 return f'<a:blip r:embed="{r_id}"/>'
4493 alpha = quantize_ooxml_alpha(opacity)
4494 return (
4495 f'<a:blip r:embed="{r_id}">'
4496 f'<a:alphaModFix amt="{alpha}"/>'
4497 '</a:blip>'
4498 )
4499
4500
4501 def _register_image_media(
4502 ctx: ConvertContext,
4503 img_format: str,
4504 img_data: bytes,
4505 *,
4506 reuse_text_fill: bool = False,
4507 ) -> str:
4508 """Register image bytes and return a DrawingML relationship id."""
4509 cache_key: tuple[str, str] | None = None
4510 if reuse_text_fill:
4511 cache_key = (img_format, hashlib.sha256(img_data).hexdigest())
4512 if cache_key in ctx.text_image_fill_cache:
4513 return ctx.text_image_fill_cache[cache_key]
4514
4515 img_idx = len(ctx.media_files) + 1
4516 img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
4517 ctx.media_files[img_filename] = img_data
4518 r_id = ctx.next_rel_id()
4519 ctx.rel_entries.append({
4520 'id': r_id,
4521 'type': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
4522 'target': f'../media/{img_filename}',
4523 })
4524 if cache_key is not None:
4525 ctx.text_image_fill_cache[cache_key] = r_id
4526 return r_id
4527
4528
4529 def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
4530 """Convert SVG <image> to DrawingML picture element.
4531
4532 Supports clip-path attribute: when present, the clipPath shape is mapped
4533 to DrawingML picture geometry (prstGeom or custGeom) so the image is
4534 natively clipped in PowerPoint.
4535 """
4536 source = load_project_image_source(elem, ctx.svg_dir)
4537
4538 # Raw coordinates (pre-context-transform) for clip path calculations
4539 raw_x = svg_length_x(elem.get('x'), ctx)
4540 raw_y = svg_length_y(elem.get('y'), ctx)
4541 raw_w = svg_length_x(elem.get('width'), ctx)
4542 raw_h = svg_length_y(elem.get('height'), ctx)
4543
4544 if ctx.use_transform_matrix:
4545 x = raw_x
4546 y = raw_y
4547 w = raw_w
4548 h = raw_h
4549 else:
4550 x = ctx_x(raw_x, ctx)
4551 y = ctx_y(raw_y, ctx)
4552 w = ctx_w(raw_w, ctx)
4553 h = ctx_h(raw_h, ctx)
4554
4555 if w <= 0 or h <= 0:
4556 raise ValueError('image width and height must be positive')
4557
4558 img_format = source.img_format
4559 img_data = source.img_data
4560
4561 transform = elem.get('transform')
4562 rendered_w, rendered_h = _picture_rendered_frame_size(
4563 ctx,
4564 raw_x,
4565 raw_y,
4566 raw_w,
4567 raw_h,
4568 x,
4569 y,
4570 w,
4571 h,
4572 transform,
4573 )
4574 align, mode = parse_project_image_aspect_ratio(
4575 elem.get('preserveAspectRatio')
4576 )
4577 src_rect = _resolve_image_src_rect_values(
4578 elem,
4579 img_data,
4580 raw_w,
4581 raw_h,
4582 )
4583 visible_source_fraction = None
4584 if align == 'none':
4585 visible_source_fraction = (1.0, 1.0)
4586 elif mode == 'slice':
4587 if src_rect is None:
4588 visible_source_fraction = (1.0, 1.0)
4589 else:
4590 src_l, src_t, src_r, src_b = src_rect
4591 visible_source_fraction = (
4592 1.0 - (src_l + src_r) / 100000.0,
4593 1.0 - (src_t + src_b) / 100000.0,
4594 )
4595 img_data, img_format = _optimize_image_for_pptx(
4596 ctx,
4597 img_data,
4598 img_format,
4599 rendered_w,
4600 rendered_h,
4601 visible_source_fraction=visible_source_fraction,
4602 )
4603
4604 r_id = _register_image_media(ctx, img_format, img_data)
4605
4606 # Resolve clip-path → DrawingML geometry
4607 clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
4608 effect_xml = ''
4609 filter_id = get_effective_filter_id(elem, ctx)
4610 if filter_id and filter_id in ctx.defs:
4611 effect_xml = build_effect_xml(
4612 ctx.defs[filter_id],
4613 get_element_opacity(elem, ctx),
4614 )
4615
4616 # Resolve preserveAspectRatio="<align> slice" as DrawingML crop metadata.
4617 # Image optimization only downscales the full source image; it never crops
4618 # pixels out of the embedded media.
4619 src_rect_xml = _src_rect_xml(src_rect)
4620 blip_xml = _build_image_blip_xml(r_id, get_element_opacity(elem, ctx))
4621
4622 # Resolve preserveAspectRatio="<align> meet" by shrinking the picture
4623 # frame to match the image's aspect ratio. Skipped when a real clip-path
4624 # produces non-trivial geometry: such clip rectangles are defined against
4625 # the original box and would no longer line up after a frame shift.
4626 # A clip-path that resolves back to the default rect geometry (e.g. plain
4627 # <rect> without rx/ry) is a no-op and must not block meet adjustment.
4628 clip_is_noop = clip_geom == '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
4629 meet_fit = None if not clip_is_noop else _resolve_image_meet_fit(elem, img_data, w, h)
4630
4631 shape_id = _claim_element_shape_id(elem, ctx)
4632 if meet_fit is not None:
4633 dx, dy, fit_w, fit_h = meet_fit
4634 if ctx.use_transform_matrix:
4635 raw_fit_x = raw_x + dx
4636 raw_fit_y = raw_y + dy
4637 raw_fit_w = fit_w
4638 raw_fit_h = fit_h
4639 else:
4640 raw_fit_x = raw_x + (dx / ctx.scale_x if ctx.scale_x else dx)
4641 raw_fit_y = raw_y + (dy / ctx.scale_y if ctx.scale_y else dy)
4642 raw_fit_w = fit_w / ctx.scale_x if ctx.scale_x else fit_w
4643 raw_fit_h = fit_h / ctx.scale_y if ctx.scale_y else fit_h
4644 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
4645 ctx,
4646 raw_fit_x,
4647 raw_fit_y,
4648 raw_fit_w,
4649 raw_fit_h,
4650 x + dx,
4651 y + dy,
4652 fit_w,
4653 fit_h,
4654 transform,
4655 )
4656 else:
4657 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
4658 ctx,
4659 raw_x,
4660 raw_y,
4661 raw_w,
4662 raw_h,
4663 x,
4664 y,
4665 w,
4666 h,
4667 transform,
4668 )
4669
4670 return ShapeResult(xml=f'''<p:pic>
4671 <p:nvPicPr>
4672 <p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
4673 <p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
4674 <p:nvPr/>
4675 </p:nvPicPr>
4676 <p:blipFill>
4677 {blip_xml}
4678 {src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
4679 </p:blipFill>
4680 <p:spPr>
4681 <a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
4682 <a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
4683 {clip_geom}
4684 {effect_xml}
4685 </p:spPr>
4686 </p:pic>''', bounds_emu=bounds_emu)
4687
4688
4689 # ---------------------------------------------------------------------------
4690 # ellipse
4691 # ---------------------------------------------------------------------------
4692
4693 def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
4694 """Convert SVG <ellipse> to DrawingML ellipse shape."""
4695 preset_geom = _build_preset_geom_from_meta(elem)
4696 raw_cx = svg_length_x(elem.get('cx'), ctx)
4697 raw_cy = svg_length_y(elem.get('cy'), ctx)
4698 rx_attr = elem.get('rx')
4699 ry_attr = elem.get('ry')
4700 raw_rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
4701 raw_ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
4702 if rx_attr is not None and ry_attr is None:
4703 raw_ry = raw_rx
4704 elif ry_attr is not None and rx_attr is None:
4705 raw_rx = raw_ry
4706 cx_ = ctx_x(raw_cx, ctx)
4707 cy_ = ctx_y(raw_cy, ctx)
4708 rx = raw_rx * ctx.scale_x
4709 ry = raw_ry * ctx.scale_y
4710
4711 if rx <= 0 or ry <= 0:
4712 return None
4713
4714 x = cx_ - rx
4715 y = cy_ - ry
4716 w = rx * 2
4717 h = ry * 2
4718
4719 fill_op = get_fill_opacity(elem, ctx)
4720 stroke_op = get_stroke_opacity(elem, ctx)
4721 fill = build_fill_xml(elem, ctx, fill_op)
4722 stroke = build_stroke_xml(elem, ctx, stroke_op)
4723
4724 geom = preset_geom or '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
4725
4726 transform = elem.get('transform')
4727
4728 shape_id = _claim_element_shape_id(elem, ctx)
4729 if preset_geom is not None:
4730 xfrm = _shape_xfrm_from_preset_frame(
4731 elem,
4732 ctx,
4733 (raw_cx - raw_rx, raw_cy - raw_ry, raw_rx * 2, raw_ry * 2),
4734 (x, y, w, h),
4735 transform,
4736 )
4737 else:
4738 xfrm = _shape_xfrm_from_svg_rect(
4739 ctx,
4740 raw_cx - raw_rx,
4741 raw_cy - raw_ry,
4742 raw_rx * 2,
4743 raw_ry * 2,
4744 x,
4745 y,
4746 w,
4747 h,
4748 transform,
4749 )
4750 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm
4751 return ShapeResult(
4752 xml=_wrap_geometry_object(
4753 elem,
4754 ctx,
4755 shape_id, f'Ellipse {shape_id}',
4756 off_x, off_y, ext_cx, ext_cy,
4757 geom, fill, stroke, xfrm_attr=xfrm_attr,
4758 ),
4759 bounds_emu=bounds_emu,
4760 )
4761
4762
4763 # ---------------------------------------------------------------------------
4764 # nested <svg> sprite (template-import round-trip)
4765 # ---------------------------------------------------------------------------
4766
4767 # Inverse of pptx_to_svg/pic_to_svg.py:101-113 — that path writes a cropped
4768 # DrawingML picture as an outer <svg viewBox> wrapping a unit-rectangle <image>.
4769 # Without this converter, every cropped picture in a template-import SVG is
4770 # silently dropped on re-export.
4771
4772 @dataclass(frozen=True)
4773 class NestedSvgCropSpec:
4774 """Validated transport fields for one imported cropped picture."""
4775
4776 image: ET.Element
4777 x: float
4778 y: float
4779 width: float
4780 height: float
4781 view_box_x: float
4782 view_box_y: float
4783 view_box_width: float
4784 view_box_height: float
4785 src_l: int
4786 src_t: int
4787 src_r: int
4788 src_b: int
4789
4790
4791 _NESTED_CROP_OUTER_ATTRIBUTES = frozenset({
4792 'clip-path',
4793 'data-pptx-crop',
4794 'data-pptx-editable',
4795 EFFECT_REASON_ATTR,
4796 EFFECT_STATUS_ATTR,
4797 'data-pptx-frame',
4798 'data-pptx-layer',
4799 'data-pptx-object',
4800 'data-pptx-carrier',
4801 'data-pptx-prst',
4802 'data-pptx-shape-id',
4803 'data-pptx-shape-name',
4804 'data-pptx-shape-scope',
4805 'id',
4806 'overflow',
4807 'preserveAspectRatio',
4808 'transform',
4809 'viewBox',
4810 'x',
4811 'y',
4812 'width',
4813 'height',
4814 })
4815 _NESTED_CROP_IMAGE_ATTRIBUTES = frozenset({
4816 'clip-path',
4817 'href',
4818 f'{{{XLINK_NS}}}href',
4819 'opacity',
4820 'preserveAspectRatio',
4821 'x',
4822 'y',
4823 'width',
4824 'height',
4825 })
4826 _DRAWINGML_PERCENTAGE_MIN = -(2 ** 31)
4827 _DRAWINGML_PERCENTAGE_MAX = 2 ** 31 - 1
4828
4829
4830 def _unsupported_nested_crop_attributes(
4831 elem: ET.Element,
4832 allowed: frozenset[str],
4833 ) -> list[str]:
4834 unsupported = []
4835 for name in elem.attrib:
4836 if name in allowed:
4837 continue
4838 unsupported.append(name.rsplit('}', 1)[-1])
4839 return sorted(unsupported)
4840
4841
4842 def parse_project_nested_svg_crop(elem: ET.Element) -> NestedSvgCropSpec:
4843 """Parse the closed nested-SVG transport written by ``pptx_to_svg``."""
4844 if elem.tag != f'{{{SVG_NS}}}svg':
4845 raise ValueError('expected an SVG-namespace nested <svg> crop wrapper')
4846
4847 unsupported = _unsupported_nested_crop_attributes(
4848 elem,
4849 _NESTED_CROP_OUTER_ATTRIBUTES,
4850 )
4851 if unsupported:
4852 raise ValueError(
4853 'nested crop <svg> has unsupported attribute(s): '
4854 + ', '.join(unsupported)
4855 )
4856 crop_marker = elem.get('data-pptx-crop')
4857 overflow = elem.get('overflow')
4858 if overflow is not None and overflow != 'hidden':
4859 raise ValueError(
4860 'nested crop overflow must be exactly "hidden" when present'
4861 )
4862 if elem.text and elem.text.strip():
4863 raise ValueError(
4864 'nested crop <svg> cannot contain non-whitespace character data'
4865 )
4866
4867 children = list(elem)
4868 if (
4869 len(children) != 1
4870 or children[0].tag != f'{{{SVG_NS}}}image'
4871 ):
4872 raise ValueError(
4873 'nested <svg> is reserved for imported picture crops; expected '
4874 'exactly one direct SVG-namespace <image> child'
4875 )
4876 image_elem = children[0]
4877 if image_elem.tail and image_elem.tail.strip():
4878 raise ValueError(
4879 'nested crop <svg> cannot contain non-whitespace character data'
4880 )
4881 if list(image_elem) or (image_elem.text and image_elem.text.strip()):
4882 raise ValueError('nested crop <image> must be an empty element')
4883
4884 unsupported = _unsupported_nested_crop_attributes(
4885 image_elem,
4886 _NESTED_CROP_IMAGE_ATTRIBUTES,
4887 )
4888 if unsupported:
4889 raise ValueError(
4890 'nested crop <image> has unsupported attribute(s): '
4891 + ', '.join(unsupported)
4892 )
4893
4894 outer_clip_path = elem.get('clip-path')
4895 inner_clip_path = image_elem.get('clip-path')
4896 if outer_clip_path is not None and inner_clip_path is not None:
4897 raise ValueError(
4898 'nested crop clip-path must occur on either the outer <svg> or '
4899 'the inner <image>, not both'
4900 )
4901 clip_path = inner_clip_path or outer_clip_path
4902 if crop_marker is not None and crop_marker != '1':
4903 raise ValueError('nested crop data-pptx-crop must be exactly "1"')
4904 if clip_path is None:
4905 if crop_marker is not None:
4906 raise ValueError(
4907 'nested crop data-pptx-crop="1" requires clip-path'
4908 )
4909 elif clip_path.strip().lower() == 'none':
4910 raise ValueError('nested crop clip-path cannot be "none"')
4911 elif crop_marker != '1':
4912 raise ValueError(
4913 'nested crop clip-path requires data-pptx-crop="1"'
4914 )
4915 if inner_clip_path is not None and overflow != 'hidden':
4916 raise ValueError(
4917 'nested crop with inner <image> clip-path requires '
4918 'overflow="hidden" on the outer <svg>'
4919 )
4920
4921 try:
4922 _project_image_href(image_elem)
4923 except ValueError as exc:
4924 raise ValueError(f'nested crop <image> {exc}') from exc
4925
4926 required_outer = (
4927 'x',
4928 'y',
4929 'width',
4930 'height',
4931 'viewBox',
4932 'preserveAspectRatio',
4933 )
4934 missing = [name for name in required_outer if elem.get(name) is None]
4935 if missing:
4936 raise ValueError(
4937 'nested crop <svg> requires explicit x, y, width, height, '
4938 'viewBox, and preserveAspectRatio="none"; missing '
4939 + ', '.join(missing)
4940 )
4941 if elem.get('preserveAspectRatio') != 'none':
4942 raise ValueError(
4943 'nested crop <svg> preserveAspectRatio must be exactly "none"'
4944 )
4945
4946 frame_values: dict[str, float] = {}
4947 for name in ('x', 'y', 'width', 'height'):
4948 raw = elem.get(name)
4949 assert raw is not None
4950 try:
4951 frame_values[name] = parse_project_geometry_length(raw, name)
4952 except ValueError as exc:
4953 raise ValueError(
4954 f'nested crop <svg> {name}={raw!r}: {exc}'
4955 ) from exc
4956 if frame_values['width'] <= 0 or frame_values['height'] <= 0:
4957 raise ValueError('nested crop <svg> width and height must be positive')
4958
4959 view_box = elem.get('viewBox') or ''
4960 view_box_tokens = view_box.strip().split()
4961 if (
4962 len(view_box_tokens) != 4
4963 or any(
4964 not is_canonical_project_geometry_length(token)
4965 for token in view_box_tokens
4966 )
4967 ):
4968 raise ValueError(
4969 'nested crop viewBox must contain four finite unitless ordinary '
4970 'decimals separated by whitespace'
4971 )
4972 vb_x, vb_y, vb_w, vb_h = (
4973 parse_project_geometry_length(token, 'x')
4974 for token in view_box_tokens
4975 )
4976 if vb_w <= 0 or vb_h <= 0:
4977 raise ValueError('nested crop viewBox width and height must be positive')
4978 src_l = round(vb_x * 100000)
4979 src_t = round(vb_y * 100000)
4980 src_r = round((1.0 - vb_x - vb_w) * 100000)
4981 src_b = round((1.0 - vb_y - vb_h) * 100000)
4982 src_values = (src_l, src_t, src_r, src_b)
4983 if (
4984 any(
4985 value < _DRAWINGML_PERCENTAGE_MIN
4986 or value > _DRAWINGML_PERCENTAGE_MAX
4987 for value in src_values
4988 )
4989 or src_l + src_r >= 100000
4990 or src_t + src_b >= 100000
4991 ):
4992 raise ValueError(
4993 'nested crop viewBox cannot be represented as a DrawingML '
4994 'srcRect with a positive visible region within the signed '
4995 'percentage range'
4996 )
4997 if not any(src_values):
4998 raise ValueError(
4999 'nested crop viewBox="0 0 1 1" is redundant; use a plain <image>'
5000 )
5001
5002 required_image_values = {
5003 'x': '0',
5004 'y': '0',
5005 'width': '1',
5006 'height': '1',
5007 'preserveAspectRatio': 'none',
5008 }
5009 invalid_image_values = [
5010 f'{name}={image_elem.get(name)!r}'
5011 for name, expected in required_image_values.items()
5012 if image_elem.get(name) != expected
5013 ]
5014 if invalid_image_values:
5015 raise ValueError(
5016 'nested crop <image> must use x="0", y="0", width="1", '
5017 'height="1", and preserveAspectRatio="none"; got '
5018 + ', '.join(invalid_image_values)
5019 )
5020
5021 return NestedSvgCropSpec(
5022 image=image_elem,
5023 x=frame_values['x'],
5024 y=frame_values['y'],
5025 width=frame_values['width'],
5026 height=frame_values['height'],
5027 view_box_x=vb_x,
5028 view_box_y=vb_y,
5029 view_box_width=vb_w,
5030 view_box_height=vb_h,
5031 src_l=src_l,
5032 src_t=src_t,
5033 src_r=src_r,
5034 src_b=src_b,
5035 )
5036
5037
5038 def project_nested_svg_crop_errors(root: ET.Element) -> list[str]:
5039 """Return contract errors for every nested SVG transport wrapper."""
5040 errors: list[str] = []
5041 parent_by_id = {
5042 id(child): parent
5043 for parent in root.iter()
5044 for child in list(parent)
5045 }
5046 for elem in root.iter():
5047 if elem is root or elem.tag.rsplit('}', 1)[-1] != 'svg':
5048 continue
5049 elem_id = (elem.get('id') or '').strip()
5050 label = f'<svg id="{elem_id}">' if elem_id else '<svg>'
5051 ancestor = parent_by_id.get(id(elem))
5052 invalid_ancestor: ET.Element | None = None
5053 while ancestor is not None and ancestor is not root:
5054 if (
5055 ancestor.tag != f'{{{SVG_NS}}}g'
5056 or ancestor.get('data-pptx-part') is not None
5057 ):
5058 invalid_ancestor = ancestor
5059 break
5060 ancestor = parent_by_id.get(id(ancestor))
5061 if invalid_ancestor is not None:
5062 errors.append(
5063 f'{label} invalid imported crop wrapper: visual ancestor chain '
5064 'may contain only ordinary <g> elements'
5065 )
5066 continue
5067 try:
5068 parse_project_nested_svg_crop(elem)
5069 except ValueError as exc:
5070 errors.append(f'{label} invalid imported crop wrapper: {exc}')
5071 return sorted(errors)
5072
5073
5074 def _resolve_nested_svg_clip_geometry(
5075 crop: NestedSvgCropSpec,
5076 image_elem: ET.Element,
5077 ctx: ConvertContext,
5078 ) -> str:
5079 """Resolve a preview-safe inner-image clip into picture geometry."""
5080 default = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
5081 clip_id = resolve_url_id(image_elem.get('clip-path', ''))
5082 if not clip_id or clip_id not in ctx.defs:
5083 return default
5084 clip_elem = ctx.defs[clip_id]
5085 shape = next(
5086 (
5087 child
5088 for child in clip_elem
5089 if child.tag.rsplit('}', 1)[-1]
5090 in {'circle', 'ellipse', 'rect', 'path', 'polygon'}
5091 ),
5092 None,
5093 )
5094 if shape is None:
5095 return default
5096 if (
5097 clip_elem.get('clipPathUnits', 'userSpaceOnUse')
5098 != 'userSpaceOnUse'
5099 ):
5100 return _resolve_clip_geometry(
5101 image_elem,
5102 ctx,
5103 crop.x,
5104 crop.y,
5105 crop.width,
5106 crop.height,
5107 )
5108
5109 shape_tag = shape.tag.rsplit('}', 1)[-1]
5110 if shape_tag == 'rect':
5111 style_values = parse_inline_style(shape.get('style'))
5112 rx_raw = style_values.get('rx') or shape.get('rx')
5113 ry_raw = style_values.get('ry') or shape.get('ry')
5114 rx = (
5115 parse_project_geometry_length(rx_raw, 'rx')
5116 if rx_raw is not None else None
5117 )
5118 ry = (
5119 parse_project_geometry_length(ry_raw, 'ry')
5120 if ry_raw is not None else None
5121 )
5122 if rx is None and ry is not None:
5123 rx = ry
5124 elif ry is None and rx is not None:
5125 ry = rx
5126 rx = rx or 0.0
5127 ry = ry or 0.0
5128 if rx <= 0 and ry <= 0:
5129 return default
5130 physical_rx = rx * crop.width / crop.view_box_width
5131 physical_ry = ry * crop.height / crop.view_box_height
5132 radius = (physical_rx + physical_ry) / 2.0
5133 shorter = min(crop.width, crop.height)
5134 if shorter <= 0:
5135 return default
5136 adj = int(min(radius / (shorter / 2.0), 1.0) * 50000)
5137 return (
5138 f'<a:prstGeom prst="roundRect"><a:avLst>'
5139 f'<a:gd name="adj" fmla="val {adj}"/>'
5140 f'</a:avLst></a:prstGeom>'
5141 )
5142
5143 return _resolve_clip_geometry(
5144 image_elem,
5145 ctx,
5146 crop.view_box_x,
5147 crop.view_box_y,
5148 crop.view_box_width,
5149 crop.view_box_height,
5150 )
5151
5152
5153 def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult:
5154 """Convert a nested <svg> sprite-crop wrapper to a DrawingML picture.
5155
5156 Pattern produced by pptx_to_svg::
5157
5158 <svg x="10" y="20" width="200" height="300" viewBox="0.5 0.3 0.5 0.7">
5159 <image href="..." x="0" y="0" width="1" height="1" preserveAspectRatio="none"/>
5160 </svg>
5161
5162 The viewBox crops the unit-rectangle inner image; that crop is mapped to a
5163 DrawingML <a:srcRect> so PowerPoint can re-crop / "Reset Picture".
5164 """
5165 crop = parse_project_nested_svg_crop(elem)
5166 image_elem = crop.image
5167 source = load_project_image_source(image_elem, ctx.svg_dir)
5168
5169 svg_x = crop.x
5170 svg_y = crop.y
5171 svg_w = crop.width
5172 svg_h = crop.height
5173
5174 if ctx.use_transform_matrix:
5175 x = svg_x
5176 y = svg_y
5177 w = svg_w
5178 h = svg_h
5179 else:
5180 x = ctx_x(svg_x, ctx)
5181 y = ctx_y(svg_y, ctx)
5182 w = ctx_w(svg_w, ctx)
5183 h = ctx_h(svg_h, ctx)
5184
5185 src_rect_xml = (
5186 f'<a:srcRect l="{crop.src_l}" t="{crop.src_t}" '
5187 f'r="{crop.src_r}" b="{crop.src_b}"/>'
5188 )
5189
5190 img_format = source.img_format
5191 img_data = source.img_data
5192
5193 transform = elem.get('transform')
5194 rendered_w, rendered_h = _picture_rendered_frame_size(
5195 ctx,
5196 svg_x,
5197 svg_y,
5198 svg_w,
5199 svg_h,
5200 x,
5201 y,
5202 w,
5203 h,
5204 transform,
5205 )
5206 img_data, img_format = _optimize_image_for_pptx(
5207 ctx,
5208 img_data,
5209 img_format,
5210 rendered_w,
5211 rendered_h,
5212 visible_source_fraction=(
5213 1.0 - (crop.src_l + crop.src_r) / 100000.0,
5214 1.0 - (crop.src_t + crop.src_b) / 100000.0,
5215 ),
5216 )
5217
5218 r_id = _register_image_media(ctx, img_format, img_data)
5219
5220 shape_id = _claim_element_shape_id(elem, ctx)
5221 xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
5222 ctx,
5223 svg_x,
5224 svg_y,
5225 svg_w,
5226 svg_h,
5227 x,
5228 y,
5229 w,
5230 h,
5231 transform,
5232 )
5233 if image_elem.get('clip-path') is not None:
5234 clip_geom = _resolve_nested_svg_clip_geometry(crop, image_elem, ctx)
5235 else:
5236 clip_geom = _resolve_clip_geometry(
5237 elem,
5238 ctx,
5239 svg_x,
5240 svg_y,
5241 svg_w,
5242 svg_h,
5243 )
5244 effect_xml = ''
5245 filter_id = get_effective_filter_id(elem, ctx)
5246 if filter_id and filter_id in ctx.defs:
5247 effect_xml = build_effect_xml(
5248 ctx.defs[filter_id],
5249 get_element_opacity(elem, ctx),
5250 )
5251 blip_xml = _build_image_blip_xml(
5252 r_id,
5253 get_element_opacity(image_elem, ctx),
5254 )
5255
5256 return ShapeResult(xml=f'''<p:pic>
5257 <p:nvPicPr>
5258 <p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
5259 <p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
5260 <p:nvPr/>
5261 </p:nvPicPr>
5262 <p:blipFill>
5263 {blip_xml}
5264 {src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
5265 </p:blipFill>
5266 <p:spPr>
5267 <a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
5268 <a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
5269 {clip_geom}
5270 {effect_xml}
5271 </p:spPr>
5272 </p:pic>''', bounds_emu=bounds_emu)
5273
5273 lines PYTHON