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