| 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 |