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