"""SVG element converters: rect, circle, line, path, polygon, polyline, text, image, ellipse.""" from __future__ import annotations import base64 import binascii import hashlib import io import math import re from dataclasses import dataclass from pathlib import Path from typing import Any from urllib.parse import unquote_to_bytes from xml.etree import ElementTree as ET from pptx_shapes import ( CONNECTOR_PRESET_TYPES, OOXML_COORDINATE_MAX, OOXML_COORDINATE_MIN, get_preset_registry, has_relationship_attributes, load_shape_type_values, validate_ooxml_xfrm, ) from pptx_effects import EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR from pptx_to_svg.preset_authoring import AUTHORING_ATTR, AUTHORING_VALUE from resource_paths import ( resolve_external_image_reference, svg_image_payload_error, ) from .context import ( TEXT_FLOW_PRESERVE, TEXT_FLOW_SPLIT, ConvertContext, ShapeResult, ) from .theme_colors import color_node_xml from .theme_fonts import theme_font_tokens from .text_properties import ( drawingml_letter_spacing, normalize_project_text_segments, parse_project_font_style, parse_project_font_weight, parse_project_letter_spacing, parse_project_text_anchor, parse_project_text_decoration, resolve_project_xml_space, ) from .utils import ( SVG_NS, XLINK_NS, ANGLE_UNIT, FONT_PX_TO_HUNDREDTHS_PT, PROJECT_IMAGE_ASPECT_RATIO_ANCHORS, px_to_emu, _f, _get_attr, parse_svg_length, svg_length_x, svg_length_y, svg_length_size, ctx_x, ctx_y, ctx_w, ctx_h, rect_to_dml_xfrm, combine_opacity, parse_hex_color, parse_svg_color, resolve_project_text_image_fill, resolve_url_id, get_effective_filter_id, parse_inline_style, parse_font_family, is_cjk_char, detect_text_lang, estimate_text_cluster_widths, font_px_to_hpt, resolve_text_run_fonts, split_project_text_clusters, text_has_rtl_characters, text_uses_rtl, is_thick_circle_shorthand, parse_project_geometry_length, is_canonical_project_geometry_length, parse_project_image_aspect_ratio, parse_project_opacity, parse_project_stroke_dasharray, quantize_ooxml_alpha, project_definition_index, matrix_multiply, parse_transform_matrix, parse_transform_operations, transform_point, _xml_escape, ) from .styles import ( build_solid_fill, build_gradient_fill, build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect, get_element_opacity, get_fill_opacity, get_stroke_opacity, ) from .paths import ( PathCommand, parse_svg_path, parse_svg_points, svg_path_to_absolute, normalize_path_commands, path_commands_to_drawingml, transform_path_commands, ) def _resolve_external_image(svg_dir: Path, href: str) -> Path: """Resolve a non-data-URI image href to a file on disk. Search order: next to the SVG (``svg_output/``), the project root, the project's ``images/`` (the single runtime image pool — template-bundled bitmaps plus AI / web / user images all live here), then ``templates/`` (legacy flat-copied template assets). Raises ``FileNotFoundError`` if none of these exist. """ candidate = resolve_external_image_reference(svg_dir, href) if candidate is not None: return candidate raise FileNotFoundError(f'External image not found: {href}') _PROJECT_IMAGE_FORMATS = { 'bmp': 'bmp', 'emf': 'emf', 'gif': 'gif', 'jpeg': 'jpg', 'jpg': 'jpg', 'png': 'png', 'svg': 'svg', 'svg+xml': 'svg', 'tif': 'tif', 'tiff': 'tiff', 'webp': 'webp', 'wmf': 'wmf', 'x-emf': 'emf', 'x-wmf': 'wmf', } _PIL_IMAGE_FORMATS = { 'bmp': 'BMP', 'gif': 'GIF', 'jpg': 'JPEG', 'png': 'PNG', 'tif': 'TIFF', 'tiff': 'TIFF', 'webp': 'WEBP', } def _normalize_project_image_format(raw: str) -> str | None: return _PROJECT_IMAGE_FORMATS.get(raw.strip().lower().lstrip('.')) def _little_uint(data: bytes, offset: int, size: int) -> int: return int.from_bytes(data[offset:offset + size], 'little', signed=False) def _valid_emf_payload(data: bytes) -> bool: """Validate the EMF header and complete bounded record stream.""" if len(data) < 88: return False header_size = _little_uint(data, 4, 4) total_size = _little_uint(data, 48, 4) record_count = _little_uint(data, 52, 4) header_palette_entries = _little_uint(data, 68, 4) if ( _little_uint(data, 0, 4) != 1 or data[40:44] != b' EMF' or header_size < 88 or header_size > total_size or total_size != len(data) or record_count < 1 ): return False offset = 0 count = 0 last_type = 0 last_size = 0 while offset < total_size: if offset + 8 > total_size: return False record_type = _little_uint(data, offset, 4) record_size = _little_uint(data, offset + 4, 4) if record_size < 8 or record_size % 4 or offset + record_size > total_size: return False record_end = offset + record_size if count == 0 and (record_type != 1 or record_size != header_size): return False if count > 0 and record_type == 1: return False if record_type == 14: if ( record_size < 20 or record_end != total_size or _little_uint(data, record_end - 4, 4) != record_size ): return False palette_entries = _little_uint(data, offset + 8, 4) palette_offset = _little_uint(data, offset + 12, 4) if ( palette_entries != header_palette_entries or palette_entries and ( palette_offset < 16 or palette_offset + palette_entries * 4 > record_size - 4 ) ): return False offset = record_end count += 1 last_type = record_type last_size = record_size return ( offset == total_size # MS-EMF counts all records. LibreOffice-generated EMF files in the # wild count records after the header, so retain that interoperable # spelling while keeping the complete stream bounded. and count in {record_count, record_count + 1} and last_type == 14 and last_size >= 20 ) def _valid_wmf_payload(data: bytes) -> bool: """Validate a standard or placeable WMF header and record stream.""" meta_offset = 0 if data.startswith(b'\xd7\xcd\xc6\x9a'): if len(data) < 40: return False checksum = 0 for offset in range(0, 20, 2): checksum ^= _little_uint(data, offset, 2) if checksum != _little_uint(data, 20, 2): return False meta_offset = 22 if len(data) < meta_offset + 24: return False meta_type = _little_uint(data, meta_offset, 2) header_words = _little_uint(data, meta_offset + 2, 2) version = _little_uint(data, meta_offset + 4, 2) total_words = _little_uint(data, meta_offset + 6, 4) max_record_words = _little_uint(data, meta_offset + 12, 4) if ( meta_type not in {1, 2} or header_words != 9 or version not in {0x0100, 0x0300} or total_words < 12 or max_record_words < 3 ): return False total_end = meta_offset + total_words * 2 if total_end != len(data): return False offset = meta_offset + header_words * 2 last_function = -1 last_record_words = 0 observed_max_record_words = 0 while offset < total_end: if offset + 6 > total_end: return False record_words = _little_uint(data, offset, 4) function = _little_uint(data, offset + 4, 2) if ( record_words < 3 or record_words > max_record_words or offset + record_words * 2 > total_end ): return False record_end = offset + record_words * 2 if function == 0 and (record_words != 3 or record_end != total_end): return False offset = record_end last_function = function last_record_words = record_words observed_max_record_words = max( observed_max_record_words, record_words, ) return ( offset == total_end and last_function == 0 and last_record_words == 3 and observed_max_record_words == max_record_words ) def _valid_project_image_payload(img_format: str, img_data: bytes) -> bool: """Return whether bytes are a supported image of the declared format.""" if not img_data: return False if img_format == 'svg': return svg_image_payload_error(img_data) is None if img_format == 'emf': return _valid_emf_payload(img_data) if img_format == 'wmf': return _valid_wmf_payload(img_data) expected = _PIL_IMAGE_FORMATS.get(img_format) if expected is None: return False try: from PIL import Image, UnidentifiedImageError # type: ignore except ImportError: return False try: with Image.open(io.BytesIO(img_data)) as image: actual = (image.format or '').upper() image.verify() except (UnidentifiedImageError, OSError, ValueError, SyntaxError): return False return actual == expected def _decode_data_image_uri(href: str) -> tuple[str, bytes] | None: """Decode and validate one closed-project image data URI.""" if not href.startswith('data:') or ',' not in href: return None header, payload = href.split(',', 1) match = re.fullmatch( r'data:image/([A-Za-z0-9.+-]+)(?:;[^;,]*)*?(?:;base64)?', header, flags=re.IGNORECASE, ) if not match: return None img_format = _normalize_project_image_format(match.group(1)) if img_format is None: return None is_base64 = any( part.strip().lower() == 'base64' for part in header.split(';')[1:] ) try: if is_base64: img_data = base64.b64decode(payload, validate=True) else: img_data = unquote_to_bytes(payload) except (ValueError, binascii.Error): return None if not _valid_project_image_payload(img_format, img_data): return None return img_format, img_data @dataclass(frozen=True) class ProjectImageSource: """Validated bytes and package extension for one SVG image reference.""" img_format: str img_data: bytes def _project_image_href(elem: ET.Element) -> str: href_keys = tuple( key for key in ('href', f'{{{XLINK_NS}}}href') if key in elem.attrib ) if len(href_keys) != 1: raise ValueError('requires exactly one href or xlink:href') href = elem.get(href_keys[0]) if href is None or not href.strip(): raise ValueError('href cannot be empty') return href def load_project_image_source( elem: ET.Element, svg_dir: Path | None, ) -> ProjectImageSource: """Load one exact SVG image source or raise a contract error.""" if elem.tag != f'{{{SVG_NS}}}image': raise ValueError('expected an SVG-namespace element') href = _project_image_href(elem) if href.startswith('data:'): decoded = _decode_data_image_uri(href) if decoded is None: raise ValueError( 'data URI must contain a supported, non-empty image with ' 'valid encoding and bytes' ) img_format, img_data = decoded return ProjectImageSource(img_format, img_data) if svg_dir is None: raise ValueError('external image requires an SVG directory context') try: img_path = _resolve_external_image(svg_dir, href) except FileNotFoundError as exc: raise ValueError(str(exc)) from exc img_format = _normalize_project_image_format(img_path.suffix) if img_format is None: raise ValueError( f'external image has unsupported file extension {img_path.suffix!r}' ) try: img_data = img_path.read_bytes() except OSError as exc: raise ValueError(f'cannot read external image {href!r}: {exc}') from exc if not _valid_project_image_payload(img_format, img_data): raise ValueError( f'external image {href!r} is empty, corrupt, or does not match ' f'its {img_path.suffix} extension' ) return ProjectImageSource(img_format, img_data) def project_image_errors( root: ET.Element, svg_dir: Path | None, *, allow_template_placeholders: bool = False, ) -> list[str]: """Return source and frame errors for exact SVG image elements.""" errors: list[str] = [] for elem in root.iter(): if elem.tag.rsplit('}', 1)[-1] != 'image': continue label = _element_contract_label(elem) if elem.tag != f'{{{SVG_NS}}}image': errors.append( f'{label} must use the SVG namespace ' f'{SVG_NS!r}' ) continue style_values = parse_inline_style(elem.get('style')) for attribute in ('width', 'height'): raw = style_values.get(attribute) if raw is None: raw = elem.get(attribute) if raw is None: errors.append( f'{label} requires explicit positive {attribute}' ) continue try: value = parse_project_geometry_length(raw, attribute) except ValueError: # The shared geometry-length contract owns syntax diagnostics. continue if value <= 0: errors.append( f'{label} {attribute} must be positive; got {raw!r}' ) try: raw_href = _project_image_href(elem) except ValueError as exc: errors.append(f'{label} invalid image source: {exc}') continue if ( allow_template_placeholders and '{{' in raw_href and '}}' in raw_href ): continue try: load_project_image_source(elem, svg_dir) except ValueError as exc: errors.append(f'{label} invalid image source: {exc}') return sorted(errors) def _wrap_shape( shape_id: int, name: str, off_x: int, off_y: int, ext_cx: int, ext_cy: int, geom_xml: str, fill_xml: str, stroke_xml: str, effect_xml: str = '', extra_xml: str = '', rot: int = 0, xfrm_attr: str = '', ) -> str: """Wrap DrawingML content into a shape element.""" rot_attr = f' rot="{rot}"' if rot else '' xfrm_attrs = f'{xfrm_attr}{rot_attr}' return f''' {geom_xml} {fill_xml} {stroke_xml} {effect_xml} {extra_xml} ''' def _wrap_connector( shape_id: int, name: str, off_x: int, off_y: int, ext_cx: int, ext_cy: int, geom_xml: str, fill_xml: str, stroke_xml: str, effect_xml: str = '', rot: int = 0, xfrm_attr: str = '', connection_xml: str = '', extra_xml: str = '', ) -> str: """Wrap DrawingML content into a native ``p:cxnSp`` connector.""" rot_attr = f' rot="{rot}"' if rot else '' xfrm_attrs = f'{xfrm_attr}{rot_attr}' return f''' {connection_xml} {geom_xml} {fill_xml} {stroke_xml} {effect_xml} {extra_xml} ''' def _wrap_geometry_object( elem: ET.Element, ctx: ConvertContext, shape_id: int, name: str, off_x: int, off_y: int, ext_cx: int, ext_cy: int, geom_xml: str, fill_xml: str, stroke_xml: str, effect_xml: str = '', xfrm_attr: str = '', ) -> str: """Wrap a semantic leaf as a shape or connector without guessing.""" name = elem.get('data-pptx-shape-name') or name shape_style_xml = _decode_shape_style(elem) object_kind = elem.get('data-pptx-object') if object_kind != 'connector': return _wrap_shape( shape_id, name, off_x, off_y, ext_cx, ext_cy, geom_xml, fill_xml, stroke_xml, effect_xml, extra_xml=shape_style_xml, xfrm_attr=xfrm_attr, ) prst = elem.get('data-pptx-prst') is_custom = elem.get('data-pptx-geometry-kind') == 'custom' if prst is None and not is_custom: raise ValueError( 'data-pptx-object="connector" requires preset or preserved ' 'custom geometry' ) return _wrap_connector( shape_id, name, off_x, off_y, ext_cx, ext_cy, geom_xml, fill_xml, stroke_xml, effect_xml, xfrm_attr=xfrm_attr, connection_xml=_connector_connection_xml(elem, ctx), extra_xml=shape_style_xml, ) def _decode_shape_style(elem: ET.Element) -> str: encoded = elem.get('data-pptx-shape-style') if not encoded: return '' try: raw = base64.b64decode(encoded, validate=True) style = ET.fromstring(raw) decoded = raw.decode('utf-8') except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc: raise ValueError(f'Invalid shape-style metadata: {exc}') from exc if style.tag != ( '{http://schemas.openxmlformats.org/presentationml/2006/main}style' ): raise ValueError('Shape-style metadata payload must be p:style') if has_relationship_attributes(style): raise ValueError( 'Shape-style metadata must not contain relationship attributes' ) return decoded def _connector_connection_xml(elem: ET.Element, ctx: ConvertContext) -> str: """Restore connector endpoint attachment using the reserved source id map.""" parts: list[str] = [] for endpoint, tag in (('start', 'stCxn'), ('end', 'endCxn')): raw_shape_id = elem.get(f'data-pptx-{endpoint}-shape-id') raw_site = elem.get(f'data-pptx-{endpoint}-site') if raw_shape_id is None and raw_site is None: continue if raw_shape_id is None or raw_site is None: raise ValueError( f'Connector {endpoint} endpoint requires both shape-id and site' ) target_scope = ( elem.get(f'data-pptx-{endpoint}-shape-scope') or elem.get('data-pptx-shape-scope') or 'slide' ) target_id = ctx.reference_shape_id(raw_shape_id, target_scope) try: site = int(raw_site) except ValueError as exc: raise ValueError( f'Invalid connector {endpoint} site {raw_site!r}' ) from exc if site < 0 or site > 0xFFFFFFFF: raise ValueError( f'Connector {endpoint} site is outside unsigned integer range: {site}' ) parts.append(f'') return ''.join(parts) def _claim_element_shape_id(elem: ET.Element, ctx: ConvertContext) -> int: return ctx.claim_shape_id( elem.get('data-pptx-shape-id'), elem.get('data-pptx-shape-scope'), ) def _context_transform_matrix(ctx: ConvertContext) -> tuple[float, float, float, float, float, float]: """Return the current context as a full SVG affine matrix.""" if ctx.use_transform_matrix: return ctx.transform_matrix return ( ctx.scale_x, 0.0, 0.0, ctx.scale_y, ctx.translate_x, ctx.translate_y, ) def _combined_transform_matrix( ctx: ConvertContext, transform: str | None, ) -> tuple[float, float, float, float, float, float]: """Compose context transform with an element-level transform attribute.""" matrix = _context_transform_matrix(ctx) if transform: matrix = matrix_multiply(matrix, parse_transform_matrix(transform)) return matrix def _uses_full_transform(ctx: ConvertContext, transform: str | None) -> bool: return ctx.use_transform_matrix or bool(transform) def _transformed_point( ctx: ConvertContext, x: float, y: float, transform: str | None, ) -> tuple[float, float]: if _uses_full_transform(ctx, transform): return transform_point(_combined_transform_matrix(ctx, transform), x, y) return ctx_x(x, ctx), ctx_y(y, ctx) def _shape_xfrm_from_svg_rect( ctx: ConvertContext, raw_x: float, raw_y: float, raw_w: float, raw_h: float, resolved_x: float, resolved_y: float, resolved_w: float, resolved_h: float, transform: str | None, *, preserve_degenerate_axes: bool = False, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Build DrawingML xfrm data for an SVG rectangle-like element.""" if _uses_full_transform(ctx, transform): return rect_to_dml_xfrm( raw_x, raw_y, raw_w, raw_h, _combined_transform_matrix(ctx, transform), preserve_degenerate_axes=preserve_degenerate_axes, ) off_x = px_to_emu(resolved_x) off_y = px_to_emu(resolved_y) ext_cx = px_to_emu(resolved_w) ext_cy = px_to_emu(resolved_h) return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy) # --------------------------------------------------------------------------- # rect # --------------------------------------------------------------------------- # Cubic-Bézier control distance for approximating a quarter circle / ellipse. # Distance from corner to control point along the tangent, expressed as a # fraction of the radius. Standard "magic number" for a 90° arc (max error # ~0.027% of the radius). _BEZIER_QUARTER_K = 0.5522847498 # The hash-locked shared catalog is the single source of truth for the 187 # ECMA-376 ``ST_ShapeType`` values. Loading it here makes exporter validation # fail closed if the catalog is missing, corrupt, or incomplete. PPTX_PRESET_SHAPE_TYPES = frozenset(load_shape_type_values()) _PPTX_AV_PREFIX = 'data-pptx-av-' _PPTX_GUIDE_NAME_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_.-]{0,63}') _PPTX_VAL_FORMULA_RE = re.compile(r'val[\t ]+([+-]?\d+)') def _parse_preset_geometry_metadata( elem: ET.Element, ) -> tuple[str | None, list[tuple[str, str]], tuple[float, float, float, float] | None]: """Parse and validate rendering-neutral preset geometry metadata.""" status = (elem.get('data-pptx-geometry-status') or '').strip() authoring = elem.get(AUTHORING_ATTR) if authoring not in {None, AUTHORING_VALUE}: raise ValueError(f'Unsupported {AUTHORING_ATTR} value {authoring!r}') if authoring == AUTHORING_VALUE: object_kind = elem.get('data-pptx-object') if object_kind not in {'shape', 'connector'}: raise ValueError( 'Authored preset metadata requires data-pptx-object=' '"shape" or "connector"' ) preset = elem.get('data-pptx-prst') if preset is None: raise ValueError('Authored preset metadata requires data-pptx-prst') if preset in CONNECTOR_PRESET_TYPES and object_kind != 'connector': raise ValueError( f'Connector preset {preset!r} requires ' 'data-pptx-object="connector"' ) if object_kind == 'connector' and preset not in CONNECTOR_PRESET_TYPES: raise ValueError( f'Authored connector requires a connector preset, got {preset!r}' ) if elem.get('data-pptx-frame') is None: raise ValueError('Authored preset metadata requires data-pptx-frame') if status not in {'', 'exact', 'unsupported'}: raise ValueError( f'Unsupported data-pptx-geometry-status {status!r}; ' 'expected exact or unsupported' ) raw_reason = elem.get('data-pptx-geometry-reason') if raw_reason is not None and status != 'unsupported': raise ValueError( 'data-pptx-geometry-reason requires ' 'data-pptx-geometry-status="unsupported"' ) if status == 'unsupported': reason = (raw_reason or 'unspecified').strip() raise ValueError(f'Unsupported source PPTX geometry: {reason}') prst = elem.get('data-pptx-prst') allowed_guide_names: frozenset[str] = frozenset() if prst is not None: if prst != prst.strip() or prst not in PPTX_PRESET_SHAPE_TYPES: raise ValueError(f'Unknown or invalid data-pptx-prst {prst!r}') allowed_guide_names = frozenset( guide.name for guide in get_preset_registry().get(prst).adjustments ) guide_formulas: dict[str, str] = {} for attr_name, raw_fmla in elem.attrib.items(): if not attr_name.startswith(_PPTX_AV_PREFIX): continue if prst is None: raise ValueError(f'{attr_name} requires data-pptx-prst') guide_name = attr_name[len(_PPTX_AV_PREFIX):] if not _PPTX_GUIDE_NAME_RE.fullmatch(guide_name): raise ValueError(f'Invalid preset adjustment guide name {guide_name!r}') if guide_name not in allowed_guide_names: raise ValueError( f'Preset {prst!r} has no adjustment guide named {guide_name!r}' ) formula = raw_fmla.strip() if not formula: raise ValueError(f'{attr_name} must not be empty') match = _PPTX_VAL_FORMULA_RE.fullmatch(formula) if match is not None: value = int(match.group(1)) if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: raise ValueError( f'{attr_name} value {value} is outside OOXML coordinate range' ) guide_formulas[guide_name] = formula # Compatibility for SVGs emitted before the generic ``data-pptx-av-*`` # contract. New imports always use the canonical full-formula attributes. if prst == 'round2SameRect': guide_names = set(guide_formulas) for guide_name, default in (('adj1', 16667), ('adj2', 0)): legacy_name = f'data-pptx-{guide_name}' if guide_name in guide_names or elem.get(legacy_name) is None: continue raw_value = elem.get(legacy_name, str(default)) try: value = int(float(raw_value)) except ValueError as exc: raise ValueError(f'{legacy_name} must be numeric, got {raw_value!r}') from exc value = max(0, min(100000, value)) guide_formulas[guide_name] = f'val {value}' guides: list[tuple[str, str]] = [] if prst is not None and guide_formulas: registry = get_preset_registry() try: evaluated = registry.evaluate( prst, 100000, 100000, adjustments=guide_formulas, ) except ValueError as exc: raise ValueError( f'Invalid adjustment formula for preset {prst!r}: {exc}' ) from exc for name, value in evaluated.adjustments.items(): if ( name in guide_formulas and not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX ): raise ValueError( f'data-pptx-av-{name} evaluates outside OOXML coordinate range' ) guides = [ (guide.name, guide_formulas[guide.name]) for guide in registry.get(prst).adjustments if guide.name in guide_formulas ] frame = None raw_frame = elem.get('data-pptx-frame') if raw_frame is not None: parts = re.split(r'[\s,]+', raw_frame.strip()) if len(parts) != 4: raise ValueError( 'data-pptx-frame must contain exactly four numbers: x y width height' ) try: frame = tuple(float(part) for part in parts) except ValueError as exc: raise ValueError(f'Invalid data-pptx-frame {raw_frame!r}') from exc if not all(math.isfinite(value) for value in frame): raise ValueError(f'data-pptx-frame must contain finite numbers, got {raw_frame!r}') is_connector = ( elem.get('data-pptx-object') == 'connector' or prst in CONNECTOR_PRESET_TYPES ) if is_connector: if frame[2] < 0 or frame[3] < 0 or (frame[2] == 0 and frame[3] == 0): raise ValueError( 'Connector data-pptx-frame dimensions must be non-negative ' f'and not both zero, got {raw_frame!r}' ) elif frame[2] <= 0 or frame[3] <= 0: raise ValueError( f'data-pptx-frame width and height must be positive, got {raw_frame!r}' ) validate_ooxml_xfrm( px_to_emu(frame[0]), px_to_emu(frame[1]), px_to_emu(frame[2]), px_to_emu(frame[3]), ) return prst, guides, frame def validate_preset_geometry_metadata(elem: ET.Element) -> list[str]: """Return native shape metadata errors for authoring-time validation.""" errors: list[str] = [] try: _parse_preset_geometry_metadata(elem) except ValueError as exc: errors.append(str(exc)) if elem.get('data-pptx-custgeom') is not None: try: _build_preserved_custom_geom(elem) except ValueError as exc: errors.append(str(exc)) if elem.get('data-pptx-shape-style') is not None: try: _decode_shape_style(elem) except ValueError as exc: errors.append(str(exc)) raw_shape_id = elem.get('data-pptx-shape-id') if raw_shape_id is not None: try: shape_id = int(raw_shape_id) except ValueError: errors.append(f'Invalid data-pptx-shape-id {raw_shape_id!r}') else: if shape_id < 2 or shape_id > 0xFFFFFFFF: errors.append( 'data-pptx-shape-id must be between 2 and 4294967295' ) scope = elem.get('data-pptx-shape-scope') if scope is not None and re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None: errors.append(f'Invalid data-pptx-shape-scope {scope!r}') for endpoint in ('start', 'end'): target = elem.get(f'data-pptx-{endpoint}-shape-id') site = elem.get(f'data-pptx-{endpoint}-site') if (target is None) != (site is None): errors.append( f'Connector {endpoint} endpoint requires both shape-id and site' ) if target is not None: try: target_id = int(target) site_id = int(site or '') except ValueError: errors.append(f'Invalid connector {endpoint} endpoint metadata') else: if target_id < 2 or target_id > 0xFFFFFFFF: errors.append(f'Connector {endpoint} shape-id is out of range') if site_id < 0 or site_id > 0xFFFFFFFF: errors.append(f'Connector {endpoint} site is out of range') return errors def _build_preset_geom_from_meta(elem: ET.Element) -> str | None: """Build validated native DrawingML preset geometry from SVG metadata.""" prst, guides, _frame = _parse_preset_geometry_metadata(elem) if prst is None: return None if not guides: return f'' guide_xml = ''.join( f'' for name, fmla in guides ) return f'{guide_xml}' def _build_preserved_custom_geom(elem: ET.Element) -> str | None: """Return unchanged native ``a:custGeom`` metadata, or mark it stale.""" kind = elem.get('data-pptx-geometry-kind') if kind is None: return None if kind != 'custom': raise ValueError(f'Unsupported data-pptx-geometry-kind {kind!r}') encoded = elem.get('data-pptx-custgeom') expected_hash = elem.get('data-pptx-geometry-sha256') if not encoded or not expected_hash: raise ValueError( 'Custom geometry metadata requires data-pptx-custgeom and ' 'data-pptx-geometry-sha256' ) actual_hash = hashlib.sha256( (elem.get('d') or '').strip().encode('utf-8') ).hexdigest() if actual_hash != expected_hash: return None try: raw = base64.b64decode(encoded, validate=True) custom = ET.fromstring(raw) decoded = raw.decode('utf-8') except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc: raise ValueError(f'Invalid custom geometry metadata: {exc}') from exc if custom.tag != ( '{http://schemas.openxmlformats.org/drawingml/2006/main}custGeom' ): raise ValueError('Custom geometry metadata payload must be a:custGeom') if has_relationship_attributes(custom): raise ValueError( 'Custom geometry metadata must not contain relationship attributes' ) return decoded def _shape_xfrm_from_preset_frame( elem: ET.Element, ctx: ConvertContext, fallback_raw_rect: tuple[float, float, float, float], fallback_resolved_rect: tuple[float, float, float, float], transform: str | None, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Use the preserved logical frame for native preset size when present.""" prst, _guides, frame = _parse_preset_geometry_metadata(elem) if frame is None: raw_x, raw_y, raw_w, raw_h = fallback_raw_rect x, y, w, h = fallback_resolved_rect else: raw_x, raw_y, raw_w, raw_h = frame x = ctx_x(raw_x, ctx) y = ctx_y(raw_y, ctx) w = ctx_w(raw_w, ctx) h = ctx_h(raw_h, ctx) preserves_zero_axis = ( elem.get('data-pptx-object') == 'connector' or prst in CONNECTOR_PRESET_TYPES ) xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect( ctx, raw_x, raw_y, raw_w, raw_h, x, y, w, h, transform, preserve_degenerate_axes=preserves_zero_axis, ) if not preserves_zero_axis: ext_cx = max(ext_cx, 1) ext_cy = max(ext_cy, 1) bounds_emu = ( bounds_emu[0], bounds_emu[1], max(bounds_emu[2], off_x + ext_cx), max(bounds_emu[3], off_y + ext_cy), ) return xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu def _pathlike_preset_xfrm( elem: ET.Element, ctx: ConvertContext, transform: str | None, min_x: float, min_y: float, width: float, height: float, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Resolve a path-like preset xfrm from its logical frame or visual bounds.""" _prst, _guides, frame = _parse_preset_geometry_metadata(elem) if frame is None: if _uses_full_transform(ctx, transform): tag = elem.tag.rsplit('}', 1)[-1] raise ValueError( f'Transformed preset-bearing <{tag}> requires data-pptx-frame ' 'to preserve its logical size' ) off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) ext_cx = max(px_to_emu(width), 1) ext_cy = max(px_to_emu(height), 1) return ( '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy), ) return _shape_xfrm_from_preset_frame( elem, ctx, (0.0, 0.0, 1.0, 1.0), (min_x, min_y, width, height), transform, ) def _build_round_rect_custgeom(w: float, h: float, rx: float, ry: float) -> str: """Build a DrawingML ``custGeom`` for a rectangle with elliptical corners. Used when ```` has rx ≠ ry, which DrawingML's preset ``roundRect`` cannot express (the preset takes a single ``adj`` shared by all four corners and is implicitly symmetric). Each 90° elliptical arc is approximated by one cubic Bézier — within 0.03% of the true ellipse, far below any visible threshold at slide resolution. Trade-off vs. the symmetric ``prstGeom roundRect`` path: this geometry is custom, so PowerPoint's yellow corner-radius handle is gone and the shape can no longer be retuned in-place. That matches the underlying reality — rx ≠ ry has no single "radius" to drag — and remains far better than the previous behaviour (silently dropping all corners and rendering a hard rectangle). Args: w, h: Pixel dimensions of the rectangle (post ctx-scale). rx, ry: Pixel corner radii along x and y. Will be clamped to half of w / h respectively per the SVG spec. Returns: A complete ``...`` XML string. Coordinates are emitted in EMU within a path-local coordinate system whose ``w`` / ``h`` equal the rectangle's pixel-converted dimensions. """ # Clamp radii (SVG spec): rx > w/2 collapses to a half-circle end. rx = min(max(rx, 0.0), w / 2) ry = min(max(ry, 0.0), h / 2) width_emu = px_to_emu(w) height_emu = px_to_emu(h) rx_emu = px_to_emu(rx) ry_emu = px_to_emu(ry) cx_off = int(round(rx_emu * _BEZIER_QUARTER_K)) cy_off = int(round(ry_emu * _BEZIER_QUARTER_K)) def pt(x: int, y: int) -> str: return f'' def cubic(c1: tuple[int, int], c2: tuple[int, int], end: tuple[int, int]) -> str: return ( f'{pt(*c1)}{pt(*c2)}{pt(*end)}' ) # Path traversed clockwise, starting just past the top-left corner. parts = [ f'{pt(rx_emu, 0)}', f'{pt(width_emu - rx_emu, 0)}', # Top-right corner: (W-Rx, 0) → (W, Ry) cubic( (width_emu - rx_emu + cx_off, 0), (width_emu, ry_emu - cy_off), (width_emu, ry_emu), ), f'{pt(width_emu, height_emu - ry_emu)}', # Bottom-right corner: (W, H-Ry) → (W-Rx, H) cubic( (width_emu, height_emu - ry_emu + cy_off), (width_emu - rx_emu + cx_off, height_emu), (width_emu - rx_emu, height_emu), ), f'{pt(rx_emu, height_emu)}', # Bottom-left corner: (Rx, H) → (0, H-Ry) cubic( (rx_emu - cx_off, height_emu), (0, height_emu - ry_emu + cy_off), (0, height_emu - ry_emu), ), f'{pt(0, ry_emu)}', # Top-left corner: (0, Ry) → (Rx, 0) cubic( (0, ry_emu - cy_off), (rx_emu - cx_off, 0), (rx_emu, 0), ), '', ] path_xml = '\n'.join(parts) return ( '' '' '' f'' f'\n{path_xml}\n' '' '' ) def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML shape. Symmetric rounded corners (rx == ry) are emitted as ``prstGeom roundRect`` so PowerPoint treats them as a native rounded-rectangle shape: the yellow adjustment handle stays draggable, and "Reset Picture / Shape" works as expected. Elliptical corners (rx != ry) fall back to plain rect geometry for now — current corpora contain none, but the branch keeps callers from silently producing distorted custom geometry if one ever appears. """ raw_x = svg_length_x(elem.get('x'), ctx) raw_y = svg_length_y(elem.get('y'), ctx) raw_w = svg_length_x(elem.get('width'), ctx) raw_h = svg_length_y(elem.get('height'), ctx) x = ctx_x(raw_x, ctx) y = ctx_y(raw_y, ctx) w = ctx_w(raw_w, ctx) h = ctx_h(raw_h, ctx) preset_geom = _build_preset_geom_from_meta(elem) if w <= 0 or h <= 0: return None # SVG spec: when only one of rx/ry is specified, the other inherits its # value. Real-world svg_output decks always write only `rx`, so ry must # be inferred to keep round corners from collapsing to zero on one axis. rx_attr = elem.get('rx') ry_attr = elem.get('ry') rx_raw = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0 ry_raw = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0 if rx_attr is not None and ry_attr is None: ry_raw = rx_raw elif ry_attr is not None and rx_attr is None: rx_raw = ry_raw rx = rx_raw * ctx.scale_x ry = ry_raw * ctx.scale_y fill_op = get_fill_opacity(elem, ctx) stroke_op = get_stroke_opacity(elem, ctx) fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) effect = '' filt_id = get_effective_filter_id(elem, ctx) if filt_id and filt_id in ctx.defs: effect = build_effect_xml( ctx.defs[filt_id], get_element_opacity(elem, ctx), ) transform = elem.get('transform') if preset_geom is not None: geom = preset_geom elif rx > 0 and abs(rx - ry) < 0.5: # Symmetric corners → native PowerPoint rounded rectangle. adj is # the corner radius as a fraction of the shorter side, in 1/1000- # percent units, capped at 50000 (= radius equals half the shorter # side, i.e. capsule end). short_side = min(w, h) radius = min(rx, short_side / 2) adj = max(0, min(50000, int(round(radius / short_side * 100000)))) geom = ( '' f'' '' ) elif rx > 0 or ry > 0: # Asymmetric corners (rx != ry) → DrawingML has no preset for # elliptical-corner rectangles, so emit a custGeom with one cubic # Bézier per 90° arc. We lose the prstGeom roundRect adjustment # handle, but symmetric and asymmetric cases now both render with # rounded corners instead of one of them silently flattening to # a hard rectangle. geom = _build_round_rect_custgeom(w, h, rx, ry) else: geom = '' shape_id = _claim_element_shape_id(elem, ctx) if preset_geom is not None: xfrm = _shape_xfrm_from_preset_frame( elem, ctx, (raw_x, raw_y, raw_w, raw_h), (x, y, w, h), transform, ) else: xfrm = _shape_xfrm_from_svg_rect( ctx, raw_x, raw_y, raw_w, raw_h, x, y, w, h, transform, ) xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm return ShapeResult( xml=_wrap_geometry_object( elem, ctx, shape_id, f'Rectangle {shape_id}', off_x, off_y, ext_cx, ext_cy, geom, fill, stroke, effect, xfrm_attr=xfrm_attr, ), bounds_emu=bounds_emu, ) # --------------------------------------------------------------------------- # circle (including donut-chart arc segments) # --------------------------------------------------------------------------- def _build_arc_ring_path( cx: float, cy: float, r: float, stroke_width: float, dash_len: float, dash_offset: float, rotate_deg: float, sx: float, sy: float, ) -> tuple[str, int, int, int, int]: """Build a filled annular-sector (donut segment) as DrawingML custGeom. SVG donut charts use stroke-dasharray on a circle to draw arc segments. DrawingML cannot reproduce this, so we convert each arc segment into a filled ring shape (outer arc -> line -> inner arc -> close). Returns: (geom_xml, min_x_emu, min_y_emu, w_emu, h_emu). """ circumference = 2 * math.pi * r if circumference <= 0: return '', 0, 0, 0, 0 start_frac = -dash_offset / circumference end_frac = start_frac + dash_len / circumference start_angle = start_frac * 2 * math.pi + math.radians(rotate_deg) end_angle = end_frac * 2 * math.pi + math.radians(rotate_deg) half_sw = stroke_width / 2 r_outer = r + half_sw r_inner = r - half_sw num_segments = max(16, int(abs(end_angle - start_angle) / (math.pi / 32))) angles = [ start_angle + (end_angle - start_angle) * i / num_segments for i in range(num_segments + 1) ] outer_pts = [(cx + r_outer * math.sin(a), cy - r_outer * math.cos(a)) for a in angles] inner_pts = [(cx + r_inner * math.sin(a), cy - r_inner * math.cos(a)) for a in reversed(angles)] all_pts = [(px * sx, py * sy) for px, py in outer_pts + inner_pts] xs = [p[0] for p in all_pts] ys = [p[1] for p in all_pts] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) width = max_x - min_x height = max_y - min_y if width < 0.5 or height < 0.5: return '', 0, 0, 0, 0 w_emu = px_to_emu(width) h_emu = px_to_emu(height) lines: list[str] = [] for i, (px, py) in enumerate(all_pts): lx = px_to_emu(px - min_x) ly = px_to_emu(py - min_y) if i == 0: lines.append(f'') else: lines.append(f'') lines.append('') path_xml = '\n'.join(lines) geom = f''' {path_xml} ''' return geom, px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu def _is_donut_circle(elem: ET.Element, ctx: ConvertContext) -> bool: """Detect if a circle uses stroke-dasharray to simulate an arc segment.""" dasharray = _get_attr(elem, 'stroke-dasharray', ctx) stroke = _get_attr(elem, 'stroke', ctx) fill = _get_attr(elem, 'fill', ctx) sw = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 0) r = svg_length_size(elem.get('r'), ctx, 0) return is_thick_circle_shorthand(dasharray, stroke, fill, sw, r) def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML ellipse or donut-arc shape.""" cx_ = svg_length_x(elem.get('cx'), ctx) cy_ = svg_length_y(elem.get('cy'), ctx) r = svg_length_size(elem.get('r'), ctx) preset_geom = _build_preset_geom_from_meta(elem) if r <= 0: return None # --- Donut-chart arc segment detection --- if preset_geom is None and _is_donut_circle(elem, ctx): dasharray = _get_attr(elem, 'stroke-dasharray', ctx) parsed_dasharray = parse_project_stroke_dasharray( dasharray, allow_zero_gap=True, ) if parsed_dasharray is None: raise ValueError('Thick-circle arc requires one dash/gap pair') _preset, dash_values = parsed_dasharray dash_len = dash_values[0] raw_dash_offset = elem.get('stroke-dashoffset') dash_offset = ( parse_project_geometry_length( raw_dash_offset, 'stroke-dashoffset', ) if raw_dash_offset is not None else 0.0 ) stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1) rotate_deg = 0.0 transform = elem.get('transform', '') if transform: operations = parse_transform_operations(transform) if len(operations) != 1 or operations[0][0] != 'rotate': raise ValueError( 'Thick-circle arc transform must be one rotate operation' ) rotate_deg = operations[0][1][0] geom, min_x, min_y, w_emu, h_emu = _build_arc_ring_path( ctx_x(cx_, ctx) / ctx.scale_x, ctx_y(cy_, ctx) / ctx.scale_y, r, stroke_width, dash_len, dash_offset, rotate_deg, ctx.scale_x, ctx.scale_y, ) if not geom: return None # Use the stroke color/gradient as fill for the arc shape stroke_val = _get_attr(elem, 'stroke', ctx) op = get_stroke_opacity(elem, ctx) grad_id = resolve_url_id(stroke_val) if stroke_val else None if grad_id and grad_id in ctx.defs: fill = build_gradient_fill( ctx.defs[grad_id], op, ctx.theme_color_spec, "fill", ) elif stroke_val: color, color_alpha = parse_svg_color(stroke_val) fill = ( build_solid_fill( color, combine_opacity(op, color_alpha), ctx.theme_color_spec, "fill", ) if color else '' ) else: fill = '' stroke_xml = '' effect = '' filt_id = get_effective_filter_id(elem, ctx) if filt_id and filt_id in ctx.defs: effect = build_effect_xml( ctx.defs[filt_id], get_element_opacity(elem, ctx), ) shape_id = _claim_element_shape_id(elem, ctx) return ShapeResult( xml=_wrap_shape( shape_id, f'Arc {shape_id}', min_x, min_y, w_emu, h_emu, geom, fill, stroke_xml, effect, ), bounds_emu=(min_x, min_y, min_x + w_emu, min_y + h_emu), ) # --- Normal circle --- transform = elem.get('transform') cx_s = ctx_x(cx_, ctx) cy_s = ctx_y(cy_, ctx) r_x = r * ctx.scale_x r_y = r * ctx.scale_y x = cx_s - r_x y = cy_s - r_y w = r_x * 2 h = r_y * 2 fill_op = get_fill_opacity(elem, ctx) stroke_op = get_stroke_opacity(elem, ctx) fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) effect = '' filt_id = get_effective_filter_id(elem, ctx) if filt_id and filt_id in ctx.defs: effect = build_effect_xml( ctx.defs[filt_id], get_element_opacity(elem, ctx), ) geom = preset_geom or '' shape_id = _claim_element_shape_id(elem, ctx) if preset_geom is not None: xfrm = _shape_xfrm_from_preset_frame( elem, ctx, (cx_ - r, cy_ - r, r * 2, r * 2), (x, y, w, h), transform, ) else: xfrm = _shape_xfrm_from_svg_rect( ctx, cx_ - r, cy_ - r, r * 2, r * 2, x, y, w, h, transform, ) xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm return ShapeResult( xml=_wrap_geometry_object( elem, ctx, shape_id, f'Ellipse {shape_id}', off_x, off_y, ext_cx, ext_cy, geom, fill, stroke, effect, xfrm_attr=xfrm_attr, ), bounds_emu=bounds_emu, ) # --------------------------------------------------------------------------- # line # --------------------------------------------------------------------------- def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML shape. Lines with marker-start / marker-end are converted using the 'line' preset geometry (prstGeom prst="line") so that PowerPoint renders native arrow heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to use custom geometry which is sufficient and avoids flipH/flipV complexity. """ preset_geom = _build_preset_geom_from_meta(elem) transform = elem.get('transform') raw_x1 = svg_length_x(elem.get('x1'), ctx) raw_y1 = svg_length_y(elem.get('y1'), ctx) raw_x2 = svg_length_x(elem.get('x2'), ctx) raw_y2 = svg_length_y(elem.get('y2'), ctx) x1, y1 = _transformed_point( ctx, raw_x1, raw_y1, transform, ) x2, y2 = _transformed_point( ctx, raw_x2, raw_y2, transform, ) min_x = min(x1, x2) min_y = min(y1, y2) stroke_op = get_stroke_opacity(elem, ctx) stroke = build_stroke_xml(elem, ctx, stroke_op) shape_id = _claim_element_shape_id(elem, ctx) off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) # Determine if this line carries arrow markers. has_marker = bool( _get_attr(elem, 'marker-start', ctx) or _get_attr(elem, 'marker-end', ctx) ) if preset_geom is not None: # The preserved logical frame, not the rendered stroke/marker bounds, # owns the native shape size. Horizontal/vertical connectors retain a # one-EMU extent on the degenerate axis as required by DrawingML. raw_w = abs(raw_x2 - raw_x1) raw_h = abs(raw_y2 - raw_y1) resolved_w = abs(x2 - x1) resolved_h = abs(y2 - y1) xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = ( _shape_xfrm_from_preset_frame( elem, ctx, (min(raw_x1, raw_x2), min(raw_y1, raw_y2), raw_w, raw_h), (min_x, min_y, resolved_w, resolved_h), transform, ) ) if not _uses_full_transform(ctx, transform): flip_attrs = [] if x1 > x2: flip_attrs.append(' flipH="1"') if y1 > y2: flip_attrs.append(' flipV="1"') xfrm_attr += ''.join(flip_attrs) xml = _wrap_geometry_object( elem, ctx, shape_id, f'Connector {shape_id}' if elem.get('data-pptx-object') == 'connector' else f'Line {shape_id}', off_x, off_y, w_emu, h_emu, preset_geom, '', stroke, xfrm_attr=xfrm_attr, ) return ShapeResult(xml=xml, bounds_emu=bounds_emu) if has_marker: # ---------------------------------------------------------------- # Preset geometry approach: prstGeom prst="line" # PowerPoint only renders headEnd / tailEnd on lines whose geometry # it can intrinsically understand as a "line" (i.e. preset or # connector shapes). Custom geometry shapes silently ignore # headEnd / tailEnd in most PowerPoint versions. # # The "line" preset draws from (0,0) to (w,h). # headEnd → placed at the start of the line = (x1, y1) # tailEnd → placed at the end of the line = (x2, y2) # We set flipH / flipV so that the preset start/end align with the # original SVG endpoints: # default (no flip) : top-left → bottom-right (x1≤x2, y1≤y2) # flipH : top-right → bottom-left (x1>x2, y1≤y2) # flipV : bottom-left → top-right (x1≤x2, y1>y2) # flipH + flipV : bottom-right → top-left (x1>x2, y1>y2) # ---------------------------------------------------------------- w = abs(x2 - x1) h = abs(y2 - y1) # DrawingML requires ext cx/cy ≥ 1 EMU w_emu = px_to_emu(w) if w > 0 else 1 h_emu = px_to_emu(h) if h > 0 else 1 flip_h = x1 > x2 flip_v = y1 > y2 flip_attr = '' if flip_h and flip_v: flip_attr = ' flipH="1" flipV="1"' elif flip_h: flip_attr = ' flipH="1"' elif flip_v: flip_attr = ' flipV="1"' xml = _wrap_shape( shape_id, f'Line {shape_id}', off_x, off_y, w_emu, h_emu, '', '', stroke, xfrm_attr=flip_attr, ) else: # ---------------------------------------------------------------- # Custom geometry (original behaviour) for plain lines. # ---------------------------------------------------------------- w = max(abs(x2 - x1), 1) h = max(abs(y2 - y1), 1) w_emu = px_to_emu(w) h_emu = px_to_emu(h) lx1 = px_to_emu(x1 - min_x) ly1 = px_to_emu(y1 - min_y) lx2 = px_to_emu(x2 - min_x) ly2 = px_to_emu(y2 - min_y) geom = ( f'' f'' f'' f'' f'' f'' f'' f'' ) xml = _wrap_shape( shape_id, f'Line {shape_id}', off_x, off_y, w_emu, h_emu, geom, '', stroke, ) return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu)) # --------------------------------------------------------------------------- # path # --------------------------------------------------------------------------- def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML custom geometry shape.""" preset_geom = _build_preset_geom_from_meta(elem) preserved_custom_geom = _build_preserved_custom_geom(elem) native_geom = preset_geom or preserved_custom_geom d = elem.get('d', '') if not d: if native_geom is not None: raise ValueError('Native-geometry requires a non-empty d attribute') return None commands = parse_svg_path(d) commands = svg_path_to_absolute(commands) commands = normalize_path_commands(commands) transform = elem.get('transform') if _uses_full_transform(ctx, transform): commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform)) path_xml, min_x, min_y, width, height = path_commands_to_drawingml( commands, 0, 0, 1.0, 1.0, ) else: path_xml, min_x, min_y, width, height = path_commands_to_drawingml( commands, ctx.translate_x, ctx.translate_y, ctx.scale_x, ctx.scale_y, ) if not path_xml: return None w_emu = px_to_emu(width) h_emu = px_to_emu(height) geom = native_geom if geom is None: geom = f''' {path_xml} ''' fill_op = get_fill_opacity(elem, ctx) stroke_op = get_stroke_opacity(elem, ctx) fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) effect = '' filt_id = get_effective_filter_id(elem, ctx) if filt_id and filt_id in ctx.defs: effect = build_effect_xml( ctx.defs[filt_id], get_element_opacity(elem, ctx), ) shape_id = _claim_element_shape_id(elem, ctx) xfrm_attr = '' off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu) if native_geom is not None: xfrm = _pathlike_preset_xfrm( elem, ctx, transform, min_x, min_y, width, height, ) xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm return ShapeResult( xml=_wrap_geometry_object( elem, ctx, shape_id, f'Freeform {shape_id}', off_x, off_y, w_emu, h_emu, geom, fill, stroke, effect, xfrm_attr=xfrm_attr, ), bounds_emu=bounds_emu, ) # --------------------------------------------------------------------------- # polygon / polyline # --------------------------------------------------------------------------- def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML custom geometry shape.""" preset_geom = _build_preset_geom_from_meta(elem) points = parse_svg_points(elem.get('points', ''), min_points=3) commands = [PathCommand('M', [points[0][0], points[0][1]])] for px_, py_ in points[1:]: commands.append(PathCommand('L', [px_, py_])) commands.append(PathCommand('Z', [])) transform = elem.get('transform') if _uses_full_transform(ctx, transform): commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform)) path_xml, min_x, min_y, width, height = path_commands_to_drawingml( commands, 0, 0, 1.0, 1.0, ) else: path_xml, min_x, min_y, width, height = path_commands_to_drawingml( commands, ctx.translate_x, ctx.translate_y, ctx.scale_x, ctx.scale_y, ) if not path_xml: return None w_emu = px_to_emu(width) h_emu = px_to_emu(height) geom = preset_geom or f''' {path_xml} ''' fill_op = get_fill_opacity(elem, ctx) stroke_op = get_stroke_opacity(elem, ctx) fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) shape_id = _claim_element_shape_id(elem, ctx) xfrm_attr = '' off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu) if preset_geom is not None: xfrm = _pathlike_preset_xfrm( elem, ctx, transform, min_x, min_y, width, height, ) xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm return ShapeResult( xml=_wrap_geometry_object( elem, ctx, shape_id, f'Polygon {shape_id}', off_x, off_y, w_emu, h_emu, geom, fill, stroke, xfrm_attr=xfrm_attr, ), bounds_emu=bounds_emu, ) def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML custom geometry shape.""" preset_geom = _build_preset_geom_from_meta(elem) points = parse_svg_points(elem.get('points', ''), min_points=2) commands = [PathCommand('M', [points[0][0], points[0][1]])] for px_, py_ in points[1:]: commands.append(PathCommand('L', [px_, py_])) transform = elem.get('transform') if _uses_full_transform(ctx, transform): commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform)) path_xml, min_x, min_y, width, height = path_commands_to_drawingml( commands, 0, 0, 1.0, 1.0, ) else: path_xml, min_x, min_y, width, height = path_commands_to_drawingml( commands, ctx.translate_x, ctx.translate_y, ctx.scale_x, ctx.scale_y, ) if not path_xml: return None w_emu = px_to_emu(width) h_emu = px_to_emu(height) geom = preset_geom or f''' {path_xml} ''' fill_op = get_fill_opacity(elem, ctx) stroke_op = get_stroke_opacity(elem, ctx) fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) shape_id = _claim_element_shape_id(elem, ctx) xfrm_attr = '' off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu) if preset_geom is not None: xfrm = _pathlike_preset_xfrm( elem, ctx, transform, min_x, min_y, width, height, ) xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm return ShapeResult( xml=_wrap_geometry_object( elem, ctx, shape_id, f'Polyline {shape_id}', off_x, off_y, w_emu, h_emu, geom, '', stroke, xfrm_attr=xfrm_attr, ), bounds_emu=bounds_emu, ) # --------------------------------------------------------------------------- # text # --------------------------------------------------------------------------- _SERIF_WIDTH_FAMILIES = { 'book antiqua', 'cambria', 'fangsong', 'garamond', 'georgia', 'kaiti', 'palatino', 'palatino linotype', 'serif', 'simsun', 'songti', 'times', 'times new roman', } _TEXTBOX_PADDING_MIN_PX = 0.5 _TEXTBOX_PADDING_MAX_PX = 2.0 _TEXTBOX_PADDING_RATIO = 0.04 # Single-line auto-fit headroom interpolates between a low-caps base and an # all-caps ceiling for each run. The crude per-char width estimate undercounts # capitals most, so all-caps runs need the ceiling to keep wrap-ignoring # renderers (LibreOffice) from folding. Applying headroom per run also prevents # a short serif label from forcing a conservative serif multiplier onto an # otherwise sans-serif line. Values are calibrated against LibreOffice renders # of all-caps bold lines, with bases left above mixed-case and CJK render # ratios; exact ratios shift with font substitution, so these carry deliberate # margin rather than tracking one environment's numbers. _TEXT_WIDTH_HEADROOM_BASE = 1.06 _TEXT_WIDTH_HEADROOM_CAPS = 1.12 _SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12 _SERIF_TEXT_WIDTH_HEADROOM_CAPS = 1.36 _TEXT_BULLET_MARKERS = { '·': '•', '•': '•', '●': '●', '▪': '▪', '■': '■', '◆': '◆', '◇': '◇', '◦': '◦', '‣': '‣', } _TEXT_BULLET_RE = re.compile( r'^(?P\s*)(?P[·•●▪■◆◇◦‣])(?P\s*)' ) def _normalize_text_run_whitespace( runs: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Apply the shared whitespace contract without losing run ownership.""" normalized: list[dict[str, Any]] = [] segments = [ (str(run.get('_xml_space', 'default')), str(run.get('text', ''))) for run in runs ] for index, text in normalize_project_text_segments(segments): run = {**runs[index], 'text': text} run.pop('_xml_space', None) normalized.append(run) return normalized def _letter_spacing_to_drawingml_spc(letter_spacing_px: float) -> str: """Convert SVG px letter spacing into DrawingML rPr@spc.""" spacing = drawingml_letter_spacing(letter_spacing_px) if spacing == 0: return '' return f' spc="{spacing}"' def _is_serif_run(run: dict[str, Any]) -> bool: """Return whether a text run uses a serif-like family.""" for family in str(run.get('font_family', '')).split(','): name = family.strip().strip("'\"").lower() if not name or name in {'sans-serif', 'sans serif'}: continue if name in _SERIF_WIDTH_FAMILIES: return True if 'serif' in name and 'sans' not in name: return True return False def _estimate_run_text_width(run: dict[str, Any]) -> float: """Estimate one run using the metrics actually emitted to DrawingML.""" text = str(run.get('text', '')) font_size_px = ( font_px_to_hpt(float(run.get('font_size', 16))) / FONT_PX_TO_HUNDREDTHS_PT ) cluster_widths = estimate_text_cluster_widths( text, font_size_px, str(run.get('font_weight', '400')), ) letter_spacing_px = ( drawingml_letter_spacing( float(run.get('letter_spacing', 0.0) or 0.0) ) / FONT_PX_TO_HUNDREDTHS_PT ) return sum(cluster_widths) + letter_spacing_px * max( len(cluster_widths) - 1, 0, ) def validate_text_run_advances(runs: list[dict[str, Any]]) -> None: """Reject negative tracking that reverses or collapses one output run.""" for run in runs: text = str(run.get('text', '')) letter_spacing = float(run.get('letter_spacing', 0.0) or 0.0) if len(split_project_text_clusters(text)) < 2 or letter_spacing >= 0: continue advance = _estimate_run_text_width(run) if advance > 0: continue snippet = re.sub(r'\s+', ' ', text) raise ValueError( 'negative letter-spacing produces a non-positive DrawingML ' f'text-run advance for {snippet!r} (advance={advance:g}px)' ) def _uppercase_fraction(runs: list[dict[str, Any]]) -> float: """Fraction of cased letters across ``runs`` that are uppercase. Caseless scripts (CJK, digits, punctuation) are ignored, so a Chinese or numeric line reports 0.0 and takes the low-caps headroom base. """ upper = 0 cased = 0 for run in runs: for ch in str(run.get('text', '')): if ch.lower() != ch.upper(): cased += 1 if ch.isupper(): upper += 1 if not cased: return 0.0 return upper / cased def _estimate_text_runs_width( runs: list[dict[str, Any]], *, include_headroom: bool = True, ) -> float: """Estimate a line of text runs. ``include_headroom`` is useful for single-line auto-fit boxes where a renderer that measures text slightly wider would otherwise wrap. The headroom scales independently with each run's family and uppercase fraction. This keeps mixed-font lines from inheriting the most conservative run's multiplier. Paragraph boxes use this value as a wrapping constraint, so adding headroom there stretches the merged text frame beyond the author's source line width. """ if not include_headroom: return sum(_estimate_run_text_width(run) for run in runs) width = 0.0 for run in runs: if _is_serif_run(run): base = _SERIF_TEXT_WIDTH_HEADROOM_BASE ceiling = _SERIF_TEXT_WIDTH_HEADROOM_CAPS else: base = _TEXT_WIDTH_HEADROOM_BASE ceiling = _TEXT_WIDTH_HEADROOM_CAPS caps = _uppercase_fraction([run]) width += _estimate_run_text_width(run) * ( base + (ceiling - base) * caps ) return width def estimate_single_line_text_frame_width( runs: list[dict[str, Any]], *, include_headroom: bool = True, ) -> float: """Estimate one DrawingML textbox width with optional safety headroom.""" content_runs, bullet = _extract_text_bullet(runs) width = _estimate_text_runs_width( content_runs, include_headroom=include_headroom, ) if bullet: font_size = ( float(content_runs[0].get('font_size', 16)) if content_runs else 16.0 ) width += _bullet_margin_px(bullet, font_size) return width def validate_single_line_text_run_advances( runs: list[dict[str, Any]], ) -> None: """Validate the runs that remain after single-line bullet promotion.""" content_runs, _bullet = _extract_text_bullet(runs) validate_text_run_advances(content_runs) def _first_nonspace_run(runs: list[dict[str, Any]]) -> dict[str, Any] | None: for run in runs: if str(run.get('text', '')).strip(): return run return None def _strip_leading_chars_from_runs( runs: list[dict[str, Any]], char_count: int, ) -> list[dict[str, Any]]: stripped: list[dict[str, Any]] = [] remaining = char_count for run in runs: if run.get('_line_break'): if remaining == 0: stripped.append(run) continue text = str(run.get('text', '')) if remaining >= len(text): remaining -= len(text) continue if remaining > 0: text = text[remaining:] remaining = 0 if text: stripped.append({**run, 'text': text}) return stripped def _take_leading_chars_from_runs( runs: list[dict[str, Any]], char_count: int, ) -> list[dict[str, Any]]: taken: list[dict[str, Any]] = [] remaining = char_count for run in runs: if remaining <= 0: break text = str(run.get('text', '')) if remaining >= len(text): prefix = text remaining -= len(text) else: prefix = text[:remaining] remaining = 0 if prefix: taken.append({**run, 'text': prefix}) return taken def _extract_text_bullet( runs: list[dict[str, Any]], ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: """Convert a leading text bullet marker into paragraph metadata.""" full_text = ''.join(str(run.get('text', '')) for run in runs) match = _TEXT_BULLET_RE.match(full_text) if not match: return runs, None if not full_text[match.end():].strip(): return runs, None marker = match.group('marker') marker_run = _first_nonspace_run(runs) or {} prefix_runs = _take_leading_chars_from_runs(runs, match.end()) replacement_prefix = _TEXT_BULLET_MARKERS.get(marker, marker) + (match.group('space') or ' ') replacement_runs = [{**marker_run, 'text': replacement_prefix}] if marker_run else [] bullet = { 'char': _TEXT_BULLET_MARKERS.get(marker, marker), 'fill': marker_run.get('fill'), 'fill_raw': marker_run.get('fill_raw'), 'opacity': marker_run.get('opacity'), 'source_prefix_width_px': _estimate_text_runs_width(prefix_runs, include_headroom=False), 'margin_px': max( _estimate_text_runs_width(replacement_runs, include_headroom=False), 8.0, ), } stripped = _strip_leading_chars_from_runs(runs, match.end()) return (stripped or runs), bullet def _bullet_margin_px(bullet: dict[str, Any], font_size: float) -> float: try: return float(bullet.get('margin_px', 0.0)) except (TypeError, ValueError): return max(font_size * 0.95, 12.0) def _bullet_indent_px(bullet: dict[str, Any], font_size: float) -> float: return -_bullet_margin_px(bullet, font_size) def _build_bullet_xml( bullet: dict[str, Any] | None, ctx: ConvertContext | None, ) -> str: if not bullet: return '' fill = bullet.get('fill') fill_raw = bullet.get('fill_raw') color, color_alpha = parse_svg_color( fill_raw if isinstance(fill_raw, str) else '' ) if color is None and isinstance(fill, str): color = parse_hex_color(fill) if color: opacity = combine_opacity(bullet.get('opacity'), color_alpha) alpha_xml = ( f'' if opacity is not None else '' ) theme_spec = ctx.theme_color_spec if ctx is not None else None color_xml = ( f'{color_node_xml(color, theme_spec, "text", alpha_xml)}' ) else: color_xml = '' return ( f'{color_xml}' f'' ) def _paragraph_pr_xml( *, algn: str, font_size: float, body_xml: str = '', bullet: dict[str, Any] | None = None, ctx: ConvertContext | None = None, rtl: bool = False, ) -> str: attrs = f'algn="{algn}"' if rtl: attrs += ' rtl="1"' if bullet: margin = px_to_emu(_bullet_margin_px(bullet, font_size)) indent = px_to_emu(_bullet_indent_px(bullet, font_size)) attrs += f' marL="{margin}" indent="{indent}"' return f'{body_xml}{_build_bullet_xml(bullet, ctx)}' def _estimate_bullet_line_width( runs: list[dict[str, Any]], default_fonts: dict[str, str], ctx: ConvertContext, ) -> float: line_runs, bullet = _extract_text_bullet(runs) line_runs = _coalesce_text_runs(line_runs, default_fonts, ctx) width = _estimate_text_runs_width(line_runs, include_headroom=False) if bullet: fs_px = float(line_runs[0].get('font_size', 16)) if line_runs else 16.0 width += _bullet_margin_px(bullet, fs_px) return width def _textbox_padding(font_size: float) -> float: """Return small text-frame slack without visibly lengthening the box.""" return max( _TEXTBOX_PADDING_MIN_PX, min(_TEXTBOX_PADDING_MAX_PX, font_size * _TEXTBOX_PADDING_RATIO), ) def drawingml_text_frame_width_emu( text_width: float, font_size: float, ) -> int: """Return the exact horizontal extent used by a generated text frame.""" return px_to_emu(text_width + _textbox_padding(font_size) * 2) def _text_opacity_ratio(value: str | None) -> float: """Parse a text opacity component and clamp it to the SVG ``0..1`` range.""" if value is None: return 1.0 return parse_project_opacity(value) def _override_run_attrs( parent_attrs: dict[str, Any], tspan: ET.Element, ctx: ConvertContext, ) -> dict[str, Any]: """Layer a tspan's styling attributes over the inherited run attrs.""" run_attrs = dict(parent_attrs) inline_style = parse_inline_style(tspan.get('style')) def tspan_attr(name: str) -> str | None: return inline_style.get(name) or tspan.get(name) object_opacity = float(run_attrs.get('_object_opacity', 1.0)) fill_opacity = float(run_attrs.get('_fill_opacity', 1.0)) stroke_opacity = float(run_attrs.get('_stroke_opacity', 1.0)) if tspan_attr('opacity') is not None: object_opacity *= _text_opacity_ratio(tspan_attr('opacity')) if tspan_attr('fill-opacity') is not None: fill_opacity = _text_opacity_ratio(tspan_attr('fill-opacity')) if tspan_attr('stroke-opacity') is not None: stroke_opacity = _text_opacity_ratio(tspan_attr('stroke-opacity')) run_attrs['_object_opacity'] = object_opacity run_attrs['_fill_opacity'] = fill_opacity run_attrs['_stroke_opacity'] = stroke_opacity effective_fill_opacity = object_opacity * fill_opacity effective_stroke_opacity = object_opacity * stroke_opacity run_attrs['opacity'] = ( effective_fill_opacity if effective_fill_opacity < 1.0 else None ) run_attrs['stroke_opacity'] = ( effective_stroke_opacity if effective_stroke_opacity < 1.0 else None ) if tspan_attr('font-weight'): run_attrs['font_weight'] = parse_project_font_weight( tspan_attr('font-weight') ).canonical if tspan_attr('fill'): child_fill = tspan_attr('fill') run_attrs['fill_raw'] = child_fill c = parse_hex_color(child_fill) if c: run_attrs['fill'] = c if tspan_attr('stroke'): run_attrs['stroke_raw'] = tspan_attr('stroke') if tspan_attr('stroke-width'): run_attrs['stroke_width'] = parse_svg_length( tspan_attr('stroke-width'), run_attrs.get('stroke_width', 1.0), font_size=float(run_attrs.get('font_size', 16)), ) resolved_font_size = ctx.text_font_sizes.get(id(tspan)) if resolved_font_size is not None: run_attrs['font_size'] = resolved_font_size * ctx.scale_y elif tspan_attr('font-size'): run_attrs['font_size'] = parse_svg_length( tspan_attr('font-size'), run_attrs['font_size'], font_size=float(run_attrs.get('font_size', 16)), ) if tspan_attr('font-family'): run_attrs['font_family'] = tspan_attr('font-family') if tspan_attr('font-style'): run_attrs['font_style'] = parse_project_font_style( tspan_attr('font-style') ).canonical if tspan_attr('text-decoration'): run_attrs['text_decoration'] = parse_project_text_decoration( tspan_attr('text-decoration') ).canonical resolved_letter_spacing = ctx.text_letter_spacings.get(id(tspan)) if resolved_letter_spacing is not None: run_attrs['letter_spacing'] = resolved_letter_spacing * ctx.scale_x elif tspan_attr('letter-spacing'): run_attrs['letter_spacing'] = parse_project_letter_spacing( tspan_attr('letter-spacing'), font_size=float(run_attrs.get('font_size', 16)), scale_x=float(run_attrs.get('_scale_x', 1.0)), ).value return run_attrs def _collect_tspan_runs( tspan: ET.Element, inherited_attrs: dict[str, Any], ctx: ConvertContext, inherited_xml_space: str = 'default', ) -> list[dict[str, Any]]: """Recursively turn a tspan subtree into runs, propagating styling through nested tspans. Order: tspan.text → (each nested child tspan's runs → that child's tail under THIS tspan's attrs). """ runs: list[dict[str, Any]] = [] own_attrs = _override_run_attrs(inherited_attrs, tspan, ctx) own_xml_space = resolve_project_xml_space(tspan, inherited_xml_space) if tspan.text: runs.append({ **own_attrs, 'text': tspan.text, '_xml_space': own_xml_space, }) for child in tspan: child_tag = child.tag.replace(f'{{{SVG_NS}}}', '') if child_tag == 'tspan': runs.extend( _collect_tspan_runs(child, own_attrs, ctx, own_xml_space) ) if child.tail: runs.append({ **own_attrs, 'text': child.tail, '_xml_space': own_xml_space, }) return runs def _build_text_runs( elem: ET.Element, parent_attrs: dict[str, Any], ctx: ConvertContext, ) -> list[dict[str, Any]]: """Build a list of text runs from a element, handling children. Each run is a dict with keys: text, fill, fill_raw, font_weight, font_style, font_family, font_size, letter_spacing. Nested tspans are walked recursively so inline format changes inside a tspan still produce distinct runs. """ runs: list[dict[str, Any]] = [] xml_space = resolve_project_xml_space(elem) if elem.text: runs.append({ **parent_attrs, 'text': elem.text, '_xml_space': xml_space, }) for child in elem: child_tag = child.tag.replace(f'{{{SVG_NS}}}', '') if child_tag == 'tspan': runs.extend( _collect_tspan_runs(child, parent_attrs, ctx, xml_space) ) if child.tail: runs.append({ **parent_attrs, 'text': child.tail, '_xml_space': xml_space, }) return _normalize_text_run_whitespace(runs) def _build_text_fill_xml( fill: str, fill_raw: str, opacity: float | None, ctx: ConvertContext | None, ) -> str: """Build DrawingML fill XML for a text run.""" if fill_raw.strip().lower() in ('none', 'transparent'): return '' paint_id = resolve_url_id(fill_raw) if paint_id and ctx and paint_id in ctx.defs: paint = ctx.defs[paint_id] paint_tag = paint.tag.rsplit('}', 1)[-1] if paint_tag in {'linearGradient', 'radialGradient'}: return build_gradient_fill( paint, opacity, ctx.theme_color_spec, "text", ) if paint_tag == 'pattern': mode, image = resolve_project_text_image_fill(paint) source = load_project_image_source(image, ctx.svg_dir) r_id = _register_image_media( ctx, source.img_format, source.img_data, reuse_text_fill=True, ) blip_xml = _build_image_blip_xml(r_id, opacity) if mode == 'stretch': fill_mode_xml = '' else: fill_mode_xml = '' return f'{blip_xml}{fill_mode_xml}' parsed_color, color_alpha = parse_svg_color(fill_raw) fill = parsed_color or fill opacity = combine_opacity(opacity, color_alpha) alpha_xml = '' if opacity is not None: alpha_xml = f'' theme_spec = ctx.theme_color_spec if ctx is not None else None return ( '' f'{color_node_xml(fill, theme_spec, "text", alpha_xml)}' '' ) def _build_text_outline_xml( run: dict[str, Any], ctx: ConvertContext | None, ) -> str: """Build DrawingML outline XML for a text run from SVG stroke attributes.""" stroke_raw = run.get('stroke_raw') if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'): return '' color, color_alpha = parse_svg_color(stroke_raw) if not color: return '' stroke_width = _f(str(run.get('stroke_width', 1.0)), 1.0) stroke_opacity = combine_opacity(run.get('stroke_opacity'), color_alpha) alpha_xml = '' if stroke_opacity is not None: alpha_xml = ( f'' ) theme_spec = ctx.theme_color_spec if ctx is not None else None return ( f'' '' f'{color_node_xml(color, theme_spec, "stroke", alpha_xml)}' '' '' ) def _build_run_properties_xml( run: dict[str, Any], default_fonts: dict[str, str], ctx: ConvertContext | None = None, effect_xml: str = '', ) -> str: """Build the final ``a:rPr`` used to compare and emit one text run.""" text = str(run['text']) fill = run.get('fill', '000000') fill_raw = run.get('fill_raw', '') fw = run.get('font_weight', '400') fs_px = run.get('font_size', 16) fstyle = run.get('font_style', '') ff = run.get('font_family', '') letter_spacing_px = float(run.get('letter_spacing', 0.0) or 0.0) opacity = run.get('opacity') text_dec = run.get('text_decoration', '') # Exported font size = fs_px * FONT_PX_TO_HUNDREDTHS_PT hundredths-of-pt, # rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt # / integer snapping — whatever the px works out to is the size, e.g. # 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt. sz = font_px_to_hpt(fs_px) b_attr = ' b="1"' if parse_project_font_weight(fw).value else '' i_attr = ' i="1"' if fstyle == 'italic' else '' underline, strike = parse_project_text_decoration( text_dec or 'none' ).value u_attr = ' u="sng"' if underline else '' strike_attr = ' strike="sngStrike"' if strike else '' spc_attr = _letter_spacing_to_drawingml_spc(letter_spacing_px) fonts = parse_font_family(ff) if ff else default_fonts run_fonts = theme_font_tokens( fonts, ctx.theme_font_spec if ctx is not None else None, ) or resolve_text_run_fonts(text, fonts) lang = detect_text_lang( text, ctx.primary_language if ctx is not None else None, ) rtl_xml = ( '\n' if text_has_rtl_characters(text) else '' ) fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx) outline_xml = _build_text_outline_xml(run, ctx) return f''' {outline_xml} {fill_xml} {effect_xml} {rtl_xml} ''' def _coalesce_text_runs( runs: list[dict[str, Any]], default_fonts: dict[str, str], ctx: ConvertContext | None, ) -> list[dict[str, Any]]: """Join adjacent runs that PowerPoint sees as one formatting run.""" merged: list[dict[str, Any]] = [] previous_properties: str | None = None for run in runs: if run.get('_line_break'): merged.append({'_line_break': True}) previous_properties = None continue text = str(run.get('text', '')) if not text: continue properties = _build_run_properties_xml(run, default_fonts, ctx) if merged and properties == previous_properties: candidate = { **merged[-1], 'text': str(merged[-1].get('text', '')) + text, } candidate_properties = _build_run_properties_xml( candidate, default_fonts, ctx, ) if candidate_properties == previous_properties: merged[-1] = candidate previous_properties = candidate_properties continue merged.append({**run, 'text': text}) previous_properties = properties return merged def _text_axis_transform(ctx: ConvertContext) -> tuple[bool, bool, bool]: """Return whether text can consume the context matrix and its axis flips.""" if not ctx.use_transform_matrix: return False, False, False a, b, c, d, _e, _f = ctx.transform_matrix if abs(b) > 1e-9 or abs(c) > 1e-9: return False, False, False return True, a < 0, d < 0 def _build_run_xml( run: dict[str, Any], default_fonts: dict[str, str], ctx: ConvertContext | None = None, effect_xml: str = '', ) -> str: """Build a single XML from a run dict. Supports gradient fills on text.""" if run.get('_line_break'): return '' text = str(run['text']) properties_xml = _build_run_properties_xml( run, default_fonts, ctx, effect_xml, ) space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else '' return f''' {properties_xml} {_xml_escape(text)} ''' def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML text shape with multi-run support.""" raw_x = svg_length_x(elem.get('x'), ctx) raw_y = svg_length_y(elem.get('y'), ctx) use_axis_transform, reflect_x, reflect_y = _text_axis_transform(ctx) if use_axis_transform: x, y = transform_point(ctx.transform_matrix, raw_x, raw_y) else: x = ctx_x(raw_x, ctx) y = ctx_y(raw_y, ctx) resolved_font_size = ctx.text_font_sizes.get(id(elem)) font_size = ( resolved_font_size * ctx.scale_y if resolved_font_size is not None else parse_svg_length( _get_attr(elem, 'font-size', ctx), 16, font_size=16, ) * ctx.scale_y ) font_weight = parse_project_font_weight( _get_attr(elem, 'font-weight', ctx) or '400' ).canonical font_family_str = _get_attr(elem, 'font-family', ctx) or '' text_anchor = parse_project_text_anchor( _get_attr(elem, 'text-anchor', ctx) or 'start' ).canonical if reflect_x: text_anchor = { 'start': 'end', 'end': 'start', }.get(text_anchor, text_anchor) fill_raw = _get_attr(elem, 'fill', ctx) or '#000000' fill_color = parse_hex_color(fill_raw) or '000000' opacity = get_fill_opacity(elem, ctx) object_opacity = get_element_opacity(elem, ctx) object_opacity = 1.0 if object_opacity is None else object_opacity fill_opacity = _text_opacity_ratio(_get_attr(elem, 'fill-opacity', ctx)) stroke_raw = _get_attr(elem, 'stroke', ctx) or '' stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1.0) stroke_opacity = get_stroke_opacity(elem, ctx) stroke_opacity_value = _text_opacity_ratio(_get_attr(elem, 'stroke-opacity', ctx)) font_style = parse_project_font_style( _get_attr(elem, 'font-style', ctx) or 'normal' ).canonical text_decoration = parse_project_text_decoration( _get_attr(elem, 'text-decoration', ctx) or 'none' ).canonical raw_letter_spacing = _get_attr(elem, 'letter-spacing', ctx) resolved_letter_spacing = ctx.text_letter_spacings.get(id(elem)) if resolved_letter_spacing is not None: letter_spacing_px = resolved_letter_spacing * ctx.scale_x elif raw_letter_spacing is not None: letter_spacing_px = parse_project_letter_spacing( raw_letter_spacing, font_size=font_size, scale_x=ctx.scale_x or 1.0, ).value else: letter_spacing_px = 0.0 fonts = parse_font_family(font_family_str) parent_attrs: dict[str, Any] = { 'fill': fill_color, 'fill_raw': fill_raw, 'font_weight': font_weight, 'font_size': font_size, 'font_family': font_family_str, 'font_style': font_style, 'text_decoration': text_decoration, 'letter_spacing': letter_spacing_px, '_scale_x': ctx.scale_x or 1.0, '_object_opacity': object_opacity, '_fill_opacity': fill_opacity, '_stroke_opacity': stroke_opacity_value, 'opacity': opacity, 'stroke_raw': stroke_raw, 'stroke_width': stroke_width, 'stroke_opacity': stroke_opacity, } # Single-frame modes annotate conservative dy-stacked text with one base # line height. Semantic paragraphs become ; authored visual rows # either become (preserve) or join for wrapping (reflow). line_height_attr = ( elem.get('data-paragraph-line-height') if ctx.text_flow != TEXT_FLOW_SPLIT else None ) line_height_px = _f(line_height_attr) if line_height_attr is not None else None paragraph_runs: list[list[dict[str, Any]]] | None = None paragraph_space_before: list[float] = [] paragraph_bullets: list[dict[str, Any] | None] = [] # Per-tspan widths (visual lines as the deck author drew them) regardless # of how many merge into one ; used to size the textbox so PowerPoint # has room to wrap text to the SVG's original line widths. visual_line_widths: list[float] = [] if line_height_px is not None and line_height_px > 0: xml_space = resolve_project_xml_space(elem) paragraph_runs = [] for child in elem: if child.tag != f'{{{SVG_NS}}}tspan': continue line_runs = _collect_tspan_runs( child, parent_attrs, ctx, xml_space, ) line_runs = _normalize_text_run_whitespace(line_runs) if not line_runs: continue visual_line_widths.append( _estimate_bullet_line_width(line_runs, fonts, ctx) ) soft_break = child.get('data-paragraph-soft-break') == '1' line_break = child.get('data-paragraph-line-break') == '1' if line_break and paragraph_runs: paragraph_runs[-1].append({'_line_break': True}) paragraph_runs[-1].extend(line_runs) elif soft_break and paragraph_runs: # Append to the previous paragraph. A Latin line-wrap needs a # space to keep two words apart (SVG used a dy break, not # punctuation); CJK wraps mid-sentence with no inter-character # space, so a joining space there is a spurious artifact. prev = paragraph_runs[-1] prev_text = prev[-1]['text'] if prev else '' next_text = line_runs[0]['text'] boundary_is_cjk = ( (prev_text and is_cjk_char(prev_text[-1])) or (next_text and is_cjk_char(next_text[0])) ) if prev and not prev_text.endswith(' ') \ and not next_text.startswith(' ') \ and not boundary_is_cjk: prev.append({ **prev[-1], 'text': ' ', 'letter_spacing': 0.0, }) prev.extend(line_runs) else: paragraph_runs.append(line_runs) sb_attr = child.get('data-paragraph-space-before') paragraph_space_before.append(_f(sb_attr) if sb_attr else 0.0) if not paragraph_runs: paragraph_runs = None paragraph_space_before = [] visual_line_widths = [] else: stripped_paragraphs: list[list[dict[str, Any]]] = [] for line_runs in paragraph_runs: stripped_runs, bullet = _extract_text_bullet(line_runs) stripped_paragraphs.append( _coalesce_text_runs(stripped_runs, fonts, ctx) ) paragraph_bullets.append(bullet) paragraph_runs = stripped_paragraphs if paragraph_runs is not None: runs = [r for line in paragraph_runs for r in line] else: runs = _build_text_runs(elem, parent_attrs, ctx) runs, single_bullet = _extract_text_bullet(runs) runs = _coalesce_text_runs(runs, fonts, ctx) is_placeholder_carrier = ( (elem.get('data-pptx-carrier') or '').strip().lower() == 'true' ) full_text = ''.join(str(r.get('text', '')) for r in runs) if runs else '' if not full_text.strip(): if not is_placeholder_carrier: return None # A declared carrier must compile to one native text shape even when its # authored visual is blank. U+200B is invisible but survives DrawingML. runs = [{**parent_attrs, 'text': '\u200b'}] paragraph_runs = None paragraph_space_before = [] paragraph_bullets = [] visual_line_widths = [] single_bullet = None # Estimate text dimensions if paragraph_runs is not None: # Use the widest authored visual line, not a reflow-joined paragraph. text_width = max(visual_line_widths) if visual_line_widths else 0.0 # Keep the authored visual-line count as the source height contract. text_height = ( line_height_px * (len(visual_line_widths) - 1) + sum(paragraph_space_before) + font_size * 1.5 ) else: text_width = _estimate_text_runs_width(runs) if single_bullet: fs_px = float(runs[0].get('font_size', font_size)) if runs else font_size text_width += _bullet_margin_px(single_bullet, fs_px) text_height = font_size * 1.5 padding = _textbox_padding(font_size) # Adjust position based on text-anchor. This first box follows the visible # glyph baseline and remains useful for reconstructing imported text-body # insets when data-pptx-frame supplies the owning PowerPoint shape frame. if text_anchor == 'middle': box_x = x - text_width / 2 - padding elif text_anchor == 'end': box_x = x - text_width - padding else: box_x = x - padding box_y = y - font_size * 0.85 box_w = text_width + padding * 2 box_h = text_height + padding if reflect_y: box_y = 2 * y - box_y - box_h visual_box_x = box_x visual_box_y = box_y exact_text_frame = None exact_text_insets: tuple[float, float, float] | None = None if elem.get('data-pptx-frame') is not None: _preset, _guides, exact_text_frame = _parse_preset_geometry_metadata(elem) if exact_text_frame is None: raise ValueError('data-pptx-frame did not resolve to a text frame') raw_frame_x, raw_frame_y, raw_frame_w, raw_frame_h = exact_text_frame if use_axis_transform: frame_x_1, frame_y_1 = transform_point( ctx.transform_matrix, raw_frame_x, raw_frame_y, ) frame_x_2, frame_y_2 = transform_point( ctx.transform_matrix, raw_frame_x + raw_frame_w, raw_frame_y + raw_frame_h, ) else: frame_x_1 = ctx_x(raw_frame_x, ctx) frame_x_2 = ctx_x(raw_frame_x + raw_frame_w, ctx) frame_y_1 = ctx_y(raw_frame_y, ctx) frame_y_2 = ctx_y(raw_frame_y + raw_frame_h, ctx) box_x = min(frame_x_1, frame_x_2) box_y = min(frame_y_1, frame_y_2) box_w = abs(frame_x_2 - frame_x_1) box_h = abs(frame_y_2 - frame_y_1) top_inset = visual_box_y - box_y if text_anchor == 'start': left_inset = x - box_x right_inset = 0.0 elif text_anchor == 'end': left_inset = 0.0 right_inset = box_x + box_w - x else: center_delta = x - (box_x + box_w / 2) left_inset = max(0.0, center_delta * 2) right_inset = max(0.0, -center_delta * 2) exact_text_insets = (left_inset, top_inset, right_inset) text_transform = elem.get('transform', '') text_operations = ( parse_transform_operations(text_transform) if text_transform else () ) translate_only = bool(text_operations) and all( name == 'translate' for name, _args in text_operations ) rotate_only = ( len(text_operations) == 1 and text_operations[0][0] == 'rotate' ) if text_operations and not (translate_only or rotate_only): raise ValueError( 'Text transform must be a translate-only list or one rotate operation' ) if translate_only and not ctx.use_transform_matrix: a, b, c, d, e, f = parse_transform_matrix(text_transform) # A pure-translate transform on a text element (hand-authored, or written # by a live-preview move) was otherwise ignored here, drifting the text. # Absorb the translation into the frame position. if ( abs(a - 1.0) < 1e-9 and abs(b) < 1e-9 and abs(c) < 1e-9 and abs(d - 1.0) < 1e-9 ): sx = ctx.scale_x or 1.0 sy = ctx.scale_y or 1.0 raw_box_x = (box_x - ctx.translate_x) / sx raw_box_y = (box_y - ctx.translate_y) / sy box_x = ctx.translate_x + sx * (a * raw_box_x + e) box_y = ctx.translate_y + sy * (d * raw_box_y + f) elif translate_only and use_axis_transform: _a, _b, _c, _d, e, f = parse_transform_matrix(text_transform) matrix_a, matrix_b, matrix_c, matrix_d, _matrix_e, _matrix_f = ( ctx.transform_matrix ) box_x += matrix_a * e + matrix_c * f box_y += matrix_b * e + matrix_d * f # Text rotation. SVG's rotate(angle [cx cy]) rotates around (cx, cy), but # DrawingML's rotates the shape around its own center. # When a pivot is given (and differs from the box center), translate the # box so its center lands where SVG would place the rotated visual center — # otherwise rotated y-axis labels etc. drift to the wrong location. text_rot = 0 if rotate_only: rotate_args = text_operations[0][1] angle_deg = rotate_args[0] if reflect_x != reflect_y: angle_deg = -angle_deg text_rot = int(angle_deg * ANGLE_UNIT) if len(rotate_args) == 3: if use_axis_transform: pivot_x, pivot_y = transform_point( ctx.transform_matrix, rotate_args[1], rotate_args[2], ) else: pivot_x = ctx_x(rotate_args[1], ctx) pivot_y = ctx_y(rotate_args[2], ctx) cx_box = box_x + box_w / 2 cy_box = box_y + box_h / 2 rad = math.radians(angle_deg) dx = cx_box - pivot_x dy = cy_box - pivot_y new_cx = pivot_x + dx * math.cos(rad) - dy * math.sin(rad) new_cy = pivot_y + dx * math.sin(rad) + dy * math.cos(rad) box_x = new_cx - box_w / 2 box_y = new_cy - box_h / 2 # Alignment algn_map = {'start': 'l', 'middle': 'ctr', 'end': 'r'} algn = algn_map.get(text_anchor, 'l') # Shadow effect shape_effect_xml = '' text_effect_xml = '' filt_id = get_effective_filter_id(elem, ctx) if filt_id and filt_id in ctx.defs: filter_elem = ctx.defs[filt_id] effect_kind = classify_filter_effect(filter_elem) if effect_kind == 'glow': text_effect_xml = build_effect_xml( filter_elem, get_element_opacity(elem, ctx), ) elif effect_kind == 'shadow': shape_effect_xml = build_effect_xml( filter_elem, get_element_opacity(elem, ctx), ) shape_id = _claim_element_shape_id(elem, ctx) rot_attr = f' rot="{text_rot}"' if text_rot else '' if paragraph_runs is not None: # SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100. line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT) ln_spc_xml = f'' paragraph_xml_chunks = [] for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets): spc_bef_xml = '' if extra_px > 0: spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT) spc_bef_xml = f'' runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line) first_text_run = next( (run for run in line if not run.get('_line_break')), None, ) p_pr_xml = _paragraph_pr_xml( algn=algn, font_size=( float(first_text_run.get('font_size', font_size)) if first_text_run is not None else font_size ), body_xml=f'{ln_spc_xml}{spc_bef_xml}', bullet=bullet, ctx=ctx, rtl=text_uses_rtl( ''.join(str(run.get('text', '')) for run in line), ctx.primary_language, ), ) paragraph_xml_chunks.append( f'\n{p_pr_xml}\n{runs_inner}\n' ) paragraphs_xml = '\n'.join(paragraph_xml_chunks) else: runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs) p_pr_xml = _paragraph_pr_xml( algn=algn, font_size=float(runs[0].get('font_size', font_size)) if runs else font_size, bullet=single_bullet, ctx=ctx, rtl=text_uses_rtl(full_text, ctx.primary_language), ) paragraphs_xml = f'\n{p_pr_xml}\n{runs_xml}\n' off_x = px_to_emu(box_x) off_y = px_to_emu(box_y) ext_cx = ( px_to_emu(box_w) if exact_text_frame is not None else drawingml_text_frame_width_emu(text_width, font_size) ) ext_cy = px_to_emu(box_h) if ext_cx < 1 or ext_cy < 1: raise ValueError( 'negative letter-spacing produces a non-positive DrawingML ' f'text-frame extent (cx={ext_cx}, cy={ext_cy})' ) validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy) validate_text_run_advances(runs) # Imported text carriers with data-pptx-frame retain the source shape frame # instead of shrinking to glyph bounds. Reconstruct insets from the SVG # anchor/baseline so the visible text stays at its imported position while # remaining ordinary editable DrawingML text. if exact_text_frame is not None: if exact_text_insets is None: raise ValueError('data-pptx-frame text insets were not resolved') left_inset, top_inset, right_inset = exact_text_insets exact_frame_wrap = ( 'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square' ) body_pr_xml = ( f'\n\n' ) # Preserve mode keeps authored boundaries and lets an ordinary # generated text box follow later manual edits, such as deleting a break. # Reflow mode keeps the source width fixed as its wrapping constraint. # Exact imported frames above and structured placeholder carriers remain # fixed regardless of text-flow mode. elif paragraph_runs is not None: paragraph_wrap = ( 'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square' ) paragraph_autofit = ( '' if ( ctx.text_flow == TEXT_FLOW_PRESERVE and not is_placeholder_carrier ) else '' ) body_pr_xml = ( f'\n{paragraph_autofit}\n' ) else: body_pr_xml = ( '\n\n' ) return ShapeResult(xml=f''' {shape_effect_xml} {body_pr_xml} {paragraphs_xml} ''', bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy)) # --------------------------------------------------------------------------- # clipPath support (image clipping) # --------------------------------------------------------------------------- def _clip_commands_to_geom( commands: list[PathCommand], img_x: float, img_y: float, img_w: float, img_h: float, object_bbox: bool, ) -> str: """Convert clip path commands to DrawingML custGeom XML. Coordinates are transformed relative to the image bounding box so that (img_x, img_y) maps to (0, 0) and (img_x+img_w, img_y+img_h) maps to (w_emu, h_emu). """ w_emu = px_to_emu(img_w) h_emu = px_to_emu(img_h) if w_emu <= 0 or h_emu <= 0: return '' def _tx(x: float) -> int: if object_bbox: return int(x * w_emu) return px_to_emu(x - img_x) def _ty(y: float) -> int: if object_bbox: return int(y * h_emu) return px_to_emu(y - img_y) parts: list[str] = [] for cmd in commands: if cmd.cmd == 'M': parts.append( f'' ) elif cmd.cmd == 'L': parts.append( f'' ) elif cmd.cmd == 'C': pts = ''.join( f'' for i in range(0, 6, 2) ) parts.append(f'{pts}') elif cmd.cmd == 'Z': parts.append('') path_inner = '\n'.join(parts) return f''' {path_inner} ''' _CLIP_SHAPE_TAGS = frozenset({'circle', 'ellipse', 'rect', 'path', 'polygon'}) _CLIP_NON_VISUAL_ELEMENTS = frozenset({ f'{{{SVG_NS}}}{tag}' for tag in ('desc', 'metadata', 'style', 'title') }) def _element_contract_label(elem: ET.Element) -> str: tag = elem.tag.rsplit('}', 1)[-1] elem_id = (elem.get('id') or '').strip() return f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>' def _unsupported_clip_rule_properties(elem: ET.Element) -> tuple[str, ...]: style_values = parse_inline_style(elem.get('style')) return tuple( name for name in ('clip-rule', 'fill-rule') if elem.get(name) is not None or name in style_values ) def _effective_clip_geometry_length( elem: ET.Element, attribute: str, *, default: float | None = None, ) -> float: style_values = parse_inline_style(elem.get('style')) raw = style_values.get(attribute) if raw is None: raw = elem.get(attribute) if raw is None: if default is None: raise ValueError(f'requires {attribute}') return default return parse_project_geometry_length(raw, attribute) def _clip_preset_geometry_error( target: ET.Element, shape: ET.Element, clip_units: str, ) -> str | None: """Reject primitive clips that cannot map to a full-frame preset.""" shape_tag = shape.tag.rsplit('}', 1)[-1].lower() if shape_tag not in {'circle', 'ellipse', 'rect'}: return None target_label = _element_contract_label(target) try: target_x = _effective_clip_geometry_length(target, 'x', default=0.0) target_y = _effective_clip_geometry_length(target, 'y', default=0.0) target_w = _effective_clip_geometry_length(target, 'width') target_h = _effective_clip_geometry_length(target, 'height') except ValueError as exc: return f'cannot validate {shape_tag} against {target_label}: {exc}' if target_w <= 0 or target_h <= 0: return ( f'cannot validate {shape_tag} against {target_label}: target ' 'width and height must be positive' ) object_bbox = clip_units == 'objectBoundingBox' expected_x = 0.0 if object_bbox else target_x expected_y = 0.0 if object_bbox else target_y expected_w = 1.0 if object_bbox else target_w expected_h = 1.0 if object_bbox else target_h def close(actual: float, expected: float) -> bool: return math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-6) try: if shape_tag == 'circle': cx = _effective_clip_geometry_length(shape, 'cx', default=0.0) cy = _effective_clip_geometry_length(shape, 'cy', default=0.0) radius = _effective_clip_geometry_length(shape, 'r', default=0.0) fits = ( close(expected_w, expected_h) and close(cx, expected_x + expected_w / 2.0) and close(cy, expected_y + expected_h / 2.0) and close(radius, expected_w / 2.0) ) elif shape_tag == 'ellipse': cx = _effective_clip_geometry_length(shape, 'cx', default=0.0) cy = _effective_clip_geometry_length(shape, 'cy', default=0.0) rx = _effective_clip_geometry_length(shape, 'rx', default=0.0) ry = _effective_clip_geometry_length(shape, 'ry', default=0.0) fits = ( close(cx, expected_x + expected_w / 2.0) and close(cy, expected_y + expected_h / 2.0) and close(rx, expected_w / 2.0) and close(ry, expected_h / 2.0) ) else: rect_x = _effective_clip_geometry_length(shape, 'x', default=0.0) rect_y = _effective_clip_geometry_length(shape, 'y', default=0.0) rect_w = _effective_clip_geometry_length(shape, 'width', default=0.0) rect_h = _effective_clip_geometry_length(shape, 'height', default=0.0) fits = ( close(rect_x, expected_x) and close(rect_y, expected_y) and close(rect_w, expected_w) and close(rect_h, expected_h) ) if fits: rx_raw = ( parse_inline_style(shape.get('style')).get('rx') or shape.get('rx') ) ry_raw = ( parse_inline_style(shape.get('style')).get('ry') or shape.get('ry') ) rx = ( parse_project_geometry_length(rx_raw, 'rx') if rx_raw is not None else None ) ry = ( parse_project_geometry_length(ry_raw, 'ry') if ry_raw is not None else None ) if rx is None and ry is not None: rx = ry elif ry is None and rx is not None: ry = rx rx = rx or 0.0 ry = ry or 0.0 if rx > 0 or ry > 0: if object_bbox: fits = close(rx * target_w, ry * target_h) else: fits = close(rx, ry) except ValueError as exc: return f'{shape_tag} geometry for {target_label} is invalid: {exc}' if fits: return None return ( f'{shape_tag} geometry must cover the complete frame of {target_label} ' 'for native preset mapping; use path or polygon for partial, offset, ' 'or non-uniform clips' ) def _nested_crop_clip_preset_geometry_error( wrapper: ET.Element, shape: ET.Element, clip_units: str, ) -> str | None: """Validate an inner-image clip against the crop wrapper's viewBox.""" if clip_units != 'userSpaceOnUse': return ( 'inner clip on a nested crop must use ' 'clipPathUnits="userSpaceOnUse" so browser and PowerPoint ' 'evaluate the visible viewBox region identically' ) try: crop = parse_project_nested_svg_crop(wrapper) except ValueError as exc: return f'cannot validate nested crop geometry: {exc}' shape_tag = shape.tag.rsplit('}', 1)[-1].lower() if shape_tag not in {'circle', 'ellipse', 'rect'}: return None expected_x = crop.view_box_x expected_y = crop.view_box_y expected_w = crop.view_box_width expected_h = crop.view_box_height def close(actual: float, expected: float) -> bool: return math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-6) try: if shape_tag == 'circle': cx = _effective_clip_geometry_length(shape, 'cx', default=0.0) cy = _effective_clip_geometry_length(shape, 'cy', default=0.0) radius = _effective_clip_geometry_length(shape, 'r', default=0.0) fits = ( close(expected_w, expected_h) and close(cx, expected_x + expected_w / 2.0) and close(cy, expected_y + expected_h / 2.0) and close(radius, expected_w / 2.0) ) elif shape_tag == 'ellipse': cx = _effective_clip_geometry_length(shape, 'cx', default=0.0) cy = _effective_clip_geometry_length(shape, 'cy', default=0.0) rx = _effective_clip_geometry_length(shape, 'rx', default=0.0) ry = _effective_clip_geometry_length(shape, 'ry', default=0.0) fits = ( close(cx, expected_x + expected_w / 2.0) and close(cy, expected_y + expected_h / 2.0) and close(rx, expected_w / 2.0) and close(ry, expected_h / 2.0) ) else: rect_x = _effective_clip_geometry_length(shape, 'x', default=0.0) rect_y = _effective_clip_geometry_length(shape, 'y', default=0.0) rect_w = _effective_clip_geometry_length(shape, 'width', default=0.0) rect_h = _effective_clip_geometry_length(shape, 'height', default=0.0) fits = ( close(rect_x, expected_x) and close(rect_y, expected_y) and close(rect_w, expected_w) and close(rect_h, expected_h) ) if fits: rx_raw = ( parse_inline_style(shape.get('style')).get('rx') or shape.get('rx') ) ry_raw = ( parse_inline_style(shape.get('style')).get('ry') or shape.get('ry') ) rx = ( parse_project_geometry_length(rx_raw, 'rx') if rx_raw is not None else None ) ry = ( parse_project_geometry_length(ry_raw, 'ry') if ry_raw is not None else None ) if rx is None and ry is not None: rx = ry elif ry is None and rx is not None: ry = rx rx = rx or 0.0 ry = ry or 0.0 if rx > 0 or ry > 0: physical_rx = rx * crop.width / expected_w physical_ry = ry * crop.height / expected_h fits = math.isclose( physical_rx, physical_ry, rel_tol=1e-9, abs_tol=1e-3, ) except ValueError as exc: return f'{shape_tag} geometry for nested crop is invalid: {exc}' if fits: return None return ( f'{shape_tag} geometry must cover the nested crop viewBox and use ' 'equal physical corner radii after viewport scaling' ) def project_clip_path_errors(root: ET.Element) -> list[str]: """Return clip-path errors that would otherwise degrade picture geometry.""" definitions, duplicates = project_definition_index(root) parent_by_id = { id(child): parent for parent in root.iter() for child in list(parent) } errors: set[str] = set() for elem in root.iter(): raw_ref = elem.get('clip-path') if raw_ref is None or raw_ref.strip().lower() == 'none': continue label = _element_contract_label(elem) is_svg_image = elem.tag == f'{{{SVG_NS}}}image' parent = parent_by_id.get(id(elem)) is_nested_crop_image = ( is_svg_image and parent is not None and parent.tag == f'{{{SVG_NS}}}svg' and parent is not root and parent.get('data-pptx-crop') == '1' ) is_imported_crop = ( elem.tag == f'{{{SVG_NS}}}svg' and elem.get('data-pptx-crop') == '1' ) if not is_svg_image and not is_imported_crop: errors.add( f'{label} clip-path is allowed only on or an imported ' 'data-pptx-crop="1" wrapper' ) match = re.fullmatch(r'url\(#([^)]+)\)', raw_ref.strip()) if match is None: errors.add( f'{label} clip-path must be an exact local url(#id) ' f'reference; got {raw_ref!r}' ) continue clip_id = match.group(1) if clip_id in duplicates: errors.add( f'{label} clip-path=url(#{clip_id}) is ambiguous because the ' 'definition id is duplicated' ) continue clip = definitions.get(clip_id) if clip is None or clip.tag != f'{{{SVG_NS}}}clipPath': errors.add( f'{label} clip-path=url(#{clip_id}) has no matching direct ' f' definition' ) continue clip_label = f'' clip_units = clip.get('clipPathUnits', 'userSpaceOnUse') if clip_units not in {'userSpaceOnUse', 'objectBoundingBox'}: errors.add( f'{clip_label} has unsupported clipPathUnits={clip_units!r}' ) if clip.get('transform'): errors.add(f'{clip_label} cannot use transform') clip_rules = _unsupported_clip_rule_properties(clip) if clip_rules: errors.add( f'{clip_label} cannot use {", ".join(clip_rules)}; native ' 'picture geometry has no equivalent winding-rule control' ) visual_children = [ child for child in list(clip) if child.tag not in _CLIP_NON_VISUAL_ELEMENTS ] if len(visual_children) != 1: errors.add( f'{clip_label} must contain exactly one direct supported shape' ) continue shape = visual_children[0] shape_tag = shape.tag.rsplit('}', 1)[-1].lower() if ( shape_tag not in _CLIP_SHAPE_TAGS or shape.tag != f'{{{SVG_NS}}}{shape_tag}' ): errors.add( f'{clip_label} child <{shape_tag}> is unsupported; use ' 'circle, ellipse, rect, path, or polygon' ) continue if shape.get('transform'): errors.add(f'{clip_label} child <{shape_tag}> cannot use transform') continue shape_rules = _unsupported_clip_rule_properties(shape) if shape_rules: errors.add( f'{clip_label} child <{shape_tag}> cannot use ' f'{", ".join(shape_rules)}; native picture geometry has no ' 'equivalent winding-rule control' ) continue if clip_units in {'userSpaceOnUse', 'objectBoundingBox'}: if is_nested_crop_image: geometry_error = _nested_crop_clip_preset_geometry_error( parent, shape, clip_units, ) else: geometry_error = _clip_preset_geometry_error( elem, shape, clip_units, ) if geometry_error is not None: errors.add(f'{clip_label} {geometry_error}') return sorted(errors) def _resolve_clip_geometry( elem: ET.Element, ctx: ConvertContext, raw_x: float, raw_y: float, raw_w: float, raw_h: float, ) -> str: """Resolve clip-path on an image element to DrawingML geometry XML. Supports: - circle / ellipse → prstGeom ellipse - rect with rx/ry → prstGeom roundRect - path / polygon → custGeom Args: elem: SVG element bearing a clip-path attribute. ctx: Conversion context (carries defs). raw_x, raw_y: Image position in SVG space (pre-ctx-transform). raw_w, raw_h: Image dimensions in SVG space (pre-ctx-transform). Returns: DrawingML geometry XML string. """ DEFAULT = '' clip_ref = elem.get('clip-path', '') if not clip_ref or clip_ref == 'none': return DEFAULT clip_id = resolve_url_id(clip_ref) if not clip_id or clip_id not in ctx.defs: return DEFAULT clip_elem = ctx.defs[clip_id] clip_tag = clip_elem.tag.replace(f'{{{SVG_NS}}}', '') if clip_tag != 'clipPath': return DEFAULT # Find the first shape child of the clipPath shape = None for child in clip_elem: child_tag = child.tag.replace(f'{{{SVG_NS}}}', '') if child_tag in ('circle', 'ellipse', 'rect', 'path', 'polygon'): shape = child break if shape is None: return DEFAULT shape_tag = shape.tag.replace(f'{{{SVG_NS}}}', '') is_obb = clip_elem.get('clipPathUnits') == 'objectBoundingBox' # --- Circle / Ellipse → preset ellipse --- if shape_tag in ('circle', 'ellipse'): return '' # --- Rect with rx/ry → preset roundRect --- if shape_tag == 'rect': rx_attr = shape.get('rx') ry_attr = shape.get('ry') rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0 ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else rx if rx <= 0 and ry <= 0: return DEFAULT # plain rect clip is a no-op r = max(rx, ry) if is_obb: r = r * min(raw_w, raw_h) shorter = min(raw_w, raw_h) if shorter <= 0: return DEFAULT adj = int(min(r / (shorter / 2), 1.0) * 50000) return ( f'' f'' f'' ) # --- Path → custGeom --- if shape_tag == 'path': d = shape.get('d', '') if not d: return DEFAULT commands = parse_svg_path(d) commands = svg_path_to_absolute(commands) commands = normalize_path_commands(commands) if not commands: return DEFAULT return _clip_commands_to_geom( commands, raw_x, raw_y, raw_w, raw_h, is_obb, ) # --- Polygon → custGeom --- if shape_tag == 'polygon': pts = parse_svg_points(shape.get('points', ''), min_points=3) commands = [PathCommand('M', [pts[0][0], pts[0][1]])] for px_, py_ in pts[1:]: commands.append(PathCommand('L', [px_, py_])) commands.append(PathCommand('Z', [])) return _clip_commands_to_geom( commands, raw_x, raw_y, raw_w, raw_h, is_obb, ) return DEFAULT # --------------------------------------------------------------------------- # image # --------------------------------------------------------------------------- def _picture_xfrm_from_rect( ctx: ConvertContext, x: float, y: float, w: float, h: float, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Build DrawingML xfrm data for a picture rectangle. Coordinates ``x``, ``y``, ``w``, ``h`` MUST already be in ctx-resolved space (i.e. callers have applied ``ctx_x`` / ``ctx_w`` upstream). When ``ctx.use_transform_matrix`` is set, raw SVG-space coordinates are expected and the matrix path applies the transform itself. """ if ctx.use_transform_matrix: return rect_to_dml_xfrm(x, y, w, h, ctx.transform_matrix) off_x = px_to_emu(x) off_y = px_to_emu(y) ext_cx = px_to_emu(w) ext_cy = px_to_emu(h) return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy) def _picture_xfrm_from_svg_rect( ctx: ConvertContext, raw_x: float, raw_y: float, raw_w: float, raw_h: float, resolved_x: float, resolved_y: float, resolved_w: float, resolved_h: float, transform: str | None, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Build picture xfrm data, honoring element-level SVG transforms. ``raw_*`` values stay in the element's source SVG coordinate space for matrix decomposition; ``resolved_*`` values are the existing scalar path. """ if ctx.use_transform_matrix: matrix = ctx.transform_matrix if transform: matrix = matrix_multiply(matrix, parse_transform_matrix(transform)) return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix) if transform: context_matrix = ( ctx.scale_x, 0.0, 0.0, ctx.scale_y, ctx.translate_x, ctx.translate_y, ) matrix = matrix_multiply(context_matrix, parse_transform_matrix(transform)) return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix) return _picture_xfrm_from_rect(ctx, resolved_x, resolved_y, resolved_w, resolved_h) def _picture_rendered_frame_size( ctx: ConvertContext, raw_x: float, raw_y: float, raw_w: float, raw_h: float, resolved_x: float, resolved_y: float, resolved_w: float, resolved_h: float, transform: str | None, ) -> tuple[float, float]: """Return final DrawingML picture-axis lengths in rendered SVG pixels.""" _attr, _x, _y, ext_cx, ext_cy, _bounds = _picture_xfrm_from_svg_rect( ctx, raw_x, raw_y, raw_w, raw_h, resolved_x, resolved_y, resolved_w, resolved_h, transform, ) emu_per_px = px_to_emu(1.0) return ext_cx / emu_per_px, ext_cy / emu_per_px def _read_svg_image_size(data: bytes) -> tuple[float, float] | None: """Read an SVG image's viewport ratio from root dimensions or viewBox.""" try: root = ET.fromstring(data) except ET.ParseError: return None if root.tag != f'{{{SVG_NS}}}svg': return None try: width = parse_svg_length(root.get('width')) height = parse_svg_length(root.get('height')) except ValueError: width = 0.0 height = 0.0 if ( width > 0 and height > 0 and math.isfinite(width) and math.isfinite(height) ): return width, height view_box = root.get('viewBox') if view_box: parts = [ part for part in re.split(r'[\s,]+', view_box.strip()) if part ] try: values = [float(part) for part in parts] except ValueError: values = [] if ( len(values) == 4 and all(math.isfinite(value) for value in values) and values[2] > 0 and values[3] > 0 ): return values[2], values[3] return None def _read_image_size(data: bytes) -> tuple[float | None, float | None]: """Read intrinsic image dimensions (width, height) from raw bytes. Used by ``convert_image`` to translate SVG ``preserveAspectRatio`` into DrawingML ```` so the original image is preserved and remains croppable inside PowerPoint. SVG images use valid root ``width`` / ``height`` as the viewport ratio and fall back to ``viewBox`` when either dimension is unavailable. Raster images use EXIF-normalized pixel dimensions. Returns ``(None, None)`` on any failure. """ svg_size = _read_svg_image_size(data) if svg_size is not None: return svg_size try: from PIL import Image, ImageOps, UnidentifiedImageError # type: ignore except ImportError: return (None, None) try: with Image.open(io.BytesIO(data)) as img: oriented = ImageOps.exif_transpose(img) try: return oriented.size finally: if oriented is not img: oriented.close() except ( UnidentifiedImageError, OSError, SyntaxError, ValueError, ZeroDivisionError, ): return (None, None) def _image_has_alpha(img: Any) -> bool: """Return whether a PIL image carries useful transparency.""" if img.mode in ('RGBA', 'LA'): return True return 'transparency' in getattr(img, 'info', {}) def _prepare_raster_for_geometry(img: Any) -> Any: """Apply EXIF orientation and materialize palette/tRNS transparency.""" try: from PIL import ImageOps # type: ignore except ImportError: return img prepared = ImageOps.exif_transpose(img) if prepared.mode == 'P': prepared = prepared.convert( 'RGBA' if _image_has_alpha(prepared) else 'RGB' ) elif ( 'transparency' in getattr(prepared, 'info', {}) and prepared.mode not in {'RGBA', 'LA'} ): prepared = prepared.convert('RGBA') return prepared def _has_exif_geometry_transform(img: Any) -> bool: """Return whether EXIF requires a physical mirror or rotation.""" try: return int(img.getexif().get(274, 1)) in range(2, 9) except (AttributeError, TypeError, ValueError): return False def _image_target_size( display_w: float, display_h: float, *, max_dimension: int | None, scale: float, ) -> tuple[int, int]: """Resolve optimized pixel dimensions from rendered SVG dimensions.""" target_w = max(1, int(round(display_w * max(scale, 1.0)))) target_h = max(1, int(round(display_h * max(scale, 1.0)))) if max_dimension and max(target_w, target_h) > max_dimension: ratio = max_dimension / max(target_w, target_h) target_w = max(1, int(round(target_w * ratio))) target_h = max(1, int(round(target_h * ratio))) return target_w, target_h def _visible_source_scale_floor( img_w: int, img_h: int, display_w: float, display_h: float, visible_source_fraction: tuple[float, float], ) -> float: """Keep each rendered axis at or below its visible source pixels.""" fraction_w, fraction_h = visible_source_fraction visible_w = img_w * fraction_w visible_h = img_h * fraction_h if visible_w <= 0 or visible_h <= 0: return 1.0 return min(1.0, max(display_w / visible_w, display_h / visible_h)) def _fit_full_image_target( img_w: int, img_h: int, display_w: float, display_h: float, *, sizing: str, max_dimension: int | None, scale: float, visible_source_fraction: tuple[float, float] | None = None, ) -> tuple[int, int]: """Size the full source image; never crop pixels. ``cap`` mode limits oversized sources unless that would leave a cropped or stretched picture with fewer visible source pixels than its final rendered frame. ``display`` mode targets the configured rendered-frame scale while retaining the same one-source-pixel-per-display-pixel floor. """ if img_w <= 0 or img_h <= 0: return (1, 1) enforce_visible_floor = visible_source_fraction is not None if enforce_visible_floor and ( any( not math.isfinite(fraction) or fraction <= 0 for fraction in visible_source_fraction ) ): return (img_w, img_h) if sizing == 'cap': ratio = 1.0 if max_dimension and max(img_w, img_h) > max_dimension: ratio = max_dimension / max(img_w, img_h) if enforce_visible_floor: ratio = max( ratio, _visible_source_scale_floor( img_w, img_h, display_w, display_h, visible_source_fraction, ), ) ratio = min(1.0, ratio) target_w = max(1, int(math.ceil(img_w * ratio))) target_h = max(1, int(math.ceil(img_h * ratio))) return target_w, target_h target_display_w, target_display_h = _image_target_size( display_w, display_h, max_dimension=None, scale=scale, ) if enforce_visible_floor: fraction_w, fraction_h = visible_source_fraction preferred_scale = min( 1.0, max( target_display_w / (img_w * fraction_w), target_display_h / (img_h * fraction_h), ), ) visible_floor = _visible_source_scale_floor( img_w, img_h, display_w, display_h, visible_source_fraction, ) resize_scale = preferred_scale if max_dimension and max(img_w, img_h) * resize_scale > max_dimension: resize_scale = max( visible_floor, max_dimension / max(img_w, img_h), ) target_w = int(math.ceil(img_w * resize_scale)) target_h = int(math.ceil(img_h * resize_scale)) else: ratio = min( target_display_w / img_w, target_display_h / img_h, 1.0, ) target_w = int(round(img_w * ratio)) target_h = int(round(img_h * ratio)) target_w = max(1, target_w) target_h = max(1, target_h) if ( not enforce_visible_floor and max_dimension and max(target_w, target_h) > max_dimension ): ratio = max_dimension / max(target_w, target_h) target_w = max(1, int(round(target_w * ratio))) target_h = max(1, int(round(target_h * ratio))) return target_w, target_h def _resize_for_target(img: Any, target_w: int, target_h: int) -> Any: """Downscale a PIL image to the target dimensions without upsampling.""" width, height = img.size if target_w >= width and target_h >= height: return img ratio = min(target_w / width, target_h / height) if ratio >= 1.0: return img try: from PIL import Image # type: ignore except ImportError: return img new_size = ( max(1, int(math.ceil(width * ratio))), max(1, int(math.ceil(height * ratio))), ) return img.resize(new_size, Image.Resampling.LANCZOS) def _encode_optimized_image(img: Any, *, prefer_jpeg: bool, quality: int) -> tuple[bytes, str] | None: """Encode a PIL image for PPTX media.""" buf = io.BytesIO() try: if prefer_jpeg and not _image_has_alpha(img): if img.mode != 'RGB': img = img.convert('RGB') img.save(buf, format='JPEG', quality=max(1, min(quality, 100)), optimize=True) return buf.getvalue(), 'jpg' if img.mode == 'P': img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB') elif img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}: img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB') img.save(buf, format='PNG', optimize=True) return buf.getvalue(), 'png' except (OSError, ValueError): return None def _optimize_image_for_pptx( ctx: ConvertContext, img_data: bytes, img_format: str, display_w: float, display_h: float, *, visible_source_fraction: tuple[float, float] | None = None, ) -> tuple[bytes, str]: """Optimize full raster image bytes for native PPTX embedding.""" if not ctx.image_optimize: return img_data, img_format if img_format.lower() in {'svg', 'emf', 'wmf'}: return img_data, img_format try: from PIL import Image, UnidentifiedImageError # type: ignore except ImportError: return img_data, img_format try: img = Image.open(io.BytesIO(img_data)) img.load() except (UnidentifiedImageError, OSError, ValueError): return img_data, img_format # Multi-frame images (animated GIF / WebP / APNG): resize/re-encode # below keeps frame 0 only, flattening the animation in the exported # PPTX. Pass the original bytes through — animations are exempt from # optimization and the size cap (before this optimizer existed, the # native path embedded raster bytes verbatim and animations survived). if getattr(img, 'is_animated', False): return img_data, img_format geometry_normalized = _has_exif_geometry_transform(img) img = _prepare_raster_for_geometry(img) target_w, target_h = _fit_full_image_target( img.size[0], img.size[1], display_w, display_h, sizing=ctx.image_sizing, max_dimension=ctx.image_max_dimension, scale=ctx.image_scale, visible_source_fraction=visible_source_fraction, ) original_size = img.size img = _resize_for_target(img, target_w, target_h) resized = img.size != original_size if ( ctx.image_sizing == 'cap' and not geometry_normalized and not resized ): return img_data, img_format # Preserve source semantics: only an original JPEG stays lossy. PNG and # other static raster formats use lossless PNG after any resize. prefer_jpeg = img_format.lower() in {'jpg', 'jpeg'} encoded = _encode_optimized_image(img, prefer_jpeg=prefer_jpeg, quality=ctx.image_quality) if encoded is None: return img_data, img_format optimized_data, optimized_format = encoded if ( not geometry_normalized and not resized and len(optimized_data) >= len(img_data) ): return img_data, img_format return optimized_data, optimized_format def _compute_slice_src_rect( img_w: float, img_h: float, box_w: float, box_h: float, align: str, ) -> tuple[int, int, int, int] | None: """Compute DrawingML ```` (l, t, r, b) for SVG slice mode. SVG ``preserveAspectRatio=" slice"`` means: scale the image so it fully covers the box (CSS object-fit: cover) and crop the overflow at the given alignment anchor. DrawingML ``srcRect`` expresses the same intent by specifying which sub-rectangle of the source image to display, in units of 1/1000 of a percent (0–100000). Returns ``None`` when no cropping is required (image and box already match) or when inputs are degenerate. """ if img_w <= 0 or img_h <= 0 or box_w <= 0 or box_h <= 0: return None # Scale factor that makes the image cover the box (cover semantics). scale = max(box_w / img_w, box_h / img_h) visible_w = box_w / scale # ≤ img_w visible_h = box_h / scale # ≤ img_h if ( math.isclose(visible_w, img_w, rel_tol=1e-9, abs_tol=1e-9) and math.isclose(visible_h, img_h, rel_tol=1e-9, abs_tol=1e-9) ): return None # No crop needed x_anchor, y_anchor = PROJECT_IMAGE_ASPECT_RATIO_ANCHORS[align] crop_w_total = max( 0, min(99999, int(round((1.0 - visible_w / img_w) * 100000))), ) crop_h_total = max( 0, min(99999, int(round((1.0 - visible_h / img_h) * 100000))), ) l = max(0, min(crop_w_total, int(round(crop_w_total * x_anchor)))) t = max(0, min(crop_h_total, int(round(crop_h_total * y_anchor)))) r = crop_w_total - l b = crop_h_total - t if not any((l, t, r, b)): return None return (l, t, r, b) def _resolve_image_src_rect_values( elem: ET.Element, img_data: bytes, box_w: float, box_h: float, ) -> tuple[int, int, int, int] | None: """Resolve DrawingML source-crop values for an SVG slice image. Slice mode is resolved into a srcRect so the original image is embedded intact and PowerPoint's crop tool / "Reset Picture" continue to work. Meet mode is handled separately by ``_resolve_image_meet_fit`` (which shrinks the picture frame to match image aspect ratio); none mode keeps the legacy stretch behaviour intentionally. """ align, mode = parse_project_image_aspect_ratio( elem.get('preserveAspectRatio') ) if align == 'none' or mode != 'slice': return None img_w, img_h = _read_image_size(img_data) if img_w is None or img_h is None: return None return _compute_slice_src_rect( float(img_w), float(img_h), box_w, box_h, align, ) def _src_rect_xml(rect: tuple[int, int, int, int] | None) -> str: """Serialize an optional DrawingML source crop.""" if rect is None: return '' l, t, r, b = rect return f'' def _resolve_image_meet_fit( elem: ET.Element, img_data: bytes, box_w: float, box_h: float, ) -> tuple[float, float, float, float] | None: """For SVG ``preserveAspectRatio=" meet"``, compute the letterboxed sub-rectangle ``(dx, dy, fit_w, fit_h)`` inside the original box that matches the image's intrinsic aspect ratio. PowerPoint has no native ``meet`` semantic — ```` fills the entire frame and would distort the image whenever the SVG container ratio differs from the source image ratio. The fix is to shrink the ```` frame itself (off + ext) so the frame and image share an aspect ratio; the stretch then fills a correctly-shaped frame. Returns ``None`` when the adjustment is not applicable: - mode is ``slice`` (handled by srcRect path) - align is ``none`` (SVG spec says: stretch — do not adjust) - intrinsic image dimensions cannot be read - frame already matches image ratio (no-op) """ align, mode = parse_project_image_aspect_ratio( elem.get('preserveAspectRatio') ) if align == 'none' or mode == 'slice': return None img_w, img_h = _read_image_size(img_data) if img_w is None or img_h is None or img_w <= 0 or img_h <= 0: return None if box_w <= 0 or box_h <= 0: return None scale = min(box_w / img_w, box_h / img_h) fit_w = img_w * scale fit_h = img_h * scale if abs(fit_w - box_w) < 0.5 and abs(fit_h - box_h) < 0.5: return None # already matches — no adjustment x_anchor, y_anchor = PROJECT_IMAGE_ASPECT_RATIO_ANCHORS[align] dx = (box_w - fit_w) * x_anchor dy = (box_h - fit_h) * y_anchor return (dx, dy, fit_w, fit_h) def _build_image_blip_xml(r_id: str, opacity: float | None) -> str: """Build an image blip with native DrawingML transparency when requested.""" if opacity is None: return f'' alpha = quantize_ooxml_alpha(opacity) return ( f'' f'' '' ) def _register_image_media( ctx: ConvertContext, img_format: str, img_data: bytes, *, reuse_text_fill: bool = False, ) -> str: """Register image bytes and return a DrawingML relationship id.""" cache_key: tuple[str, str] | None = None if reuse_text_fill: cache_key = (img_format, hashlib.sha256(img_data).hexdigest()) if cache_key in ctx.text_image_fill_cache: return ctx.text_image_fill_cache[cache_key] img_idx = len(ctx.media_files) + 1 img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}' ctx.media_files[img_filename] = img_data r_id = ctx.next_rel_id() ctx.rel_entries.append({ 'id': r_id, 'type': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', 'target': f'../media/{img_filename}', }) if cache_key is not None: ctx.text_image_fill_cache[cache_key] = r_id return r_id def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML picture element. Supports clip-path attribute: when present, the clipPath shape is mapped to DrawingML picture geometry (prstGeom or custGeom) so the image is natively clipped in PowerPoint. """ source = load_project_image_source(elem, ctx.svg_dir) # Raw coordinates (pre-context-transform) for clip path calculations raw_x = svg_length_x(elem.get('x'), ctx) raw_y = svg_length_y(elem.get('y'), ctx) raw_w = svg_length_x(elem.get('width'), ctx) raw_h = svg_length_y(elem.get('height'), ctx) if ctx.use_transform_matrix: x = raw_x y = raw_y w = raw_w h = raw_h else: x = ctx_x(raw_x, ctx) y = ctx_y(raw_y, ctx) w = ctx_w(raw_w, ctx) h = ctx_h(raw_h, ctx) if w <= 0 or h <= 0: raise ValueError('image width and height must be positive') img_format = source.img_format img_data = source.img_data transform = elem.get('transform') rendered_w, rendered_h = _picture_rendered_frame_size( ctx, raw_x, raw_y, raw_w, raw_h, x, y, w, h, transform, ) align, mode = parse_project_image_aspect_ratio( elem.get('preserveAspectRatio') ) src_rect = _resolve_image_src_rect_values( elem, img_data, raw_w, raw_h, ) visible_source_fraction = None if align == 'none': visible_source_fraction = (1.0, 1.0) elif mode == 'slice': if src_rect is None: visible_source_fraction = (1.0, 1.0) else: src_l, src_t, src_r, src_b = src_rect visible_source_fraction = ( 1.0 - (src_l + src_r) / 100000.0, 1.0 - (src_t + src_b) / 100000.0, ) img_data, img_format = _optimize_image_for_pptx( ctx, img_data, img_format, rendered_w, rendered_h, visible_source_fraction=visible_source_fraction, ) r_id = _register_image_media(ctx, img_format, img_data) # Resolve clip-path → DrawingML geometry clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h) effect_xml = '' filter_id = get_effective_filter_id(elem, ctx) if filter_id and filter_id in ctx.defs: effect_xml = build_effect_xml( ctx.defs[filter_id], get_element_opacity(elem, ctx), ) # Resolve preserveAspectRatio=" slice" as DrawingML crop metadata. # Image optimization only downscales the full source image; it never crops # pixels out of the embedded media. src_rect_xml = _src_rect_xml(src_rect) blip_xml = _build_image_blip_xml(r_id, get_element_opacity(elem, ctx)) # Resolve preserveAspectRatio=" meet" by shrinking the picture # frame to match the image's aspect ratio. Skipped when a real clip-path # produces non-trivial geometry: such clip rectangles are defined against # the original box and would no longer line up after a frame shift. # A clip-path that resolves back to the default rect geometry (e.g. plain # without rx/ry) is a no-op and must not block meet adjustment. clip_is_noop = clip_geom == '' meet_fit = None if not clip_is_noop else _resolve_image_meet_fit(elem, img_data, w, h) shape_id = _claim_element_shape_id(elem, ctx) if meet_fit is not None: dx, dy, fit_w, fit_h = meet_fit if ctx.use_transform_matrix: raw_fit_x = raw_x + dx raw_fit_y = raw_y + dy raw_fit_w = fit_w raw_fit_h = fit_h else: raw_fit_x = raw_x + (dx / ctx.scale_x if ctx.scale_x else dx) raw_fit_y = raw_y + (dy / ctx.scale_y if ctx.scale_y else dy) raw_fit_w = fit_w / ctx.scale_x if ctx.scale_x else fit_w raw_fit_h = fit_h / ctx.scale_y if ctx.scale_y else fit_h xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect( ctx, raw_fit_x, raw_fit_y, raw_fit_w, raw_fit_h, x + dx, y + dy, fit_w, fit_h, transform, ) else: xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect( ctx, raw_x, raw_y, raw_w, raw_h, x, y, w, h, transform, ) return ShapeResult(xml=f''' {blip_xml} {src_rect_xml} {clip_geom} {effect_xml} ''', bounds_emu=bounds_emu) # --------------------------------------------------------------------------- # ellipse # --------------------------------------------------------------------------- def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML ellipse shape.""" preset_geom = _build_preset_geom_from_meta(elem) raw_cx = svg_length_x(elem.get('cx'), ctx) raw_cy = svg_length_y(elem.get('cy'), ctx) rx_attr = elem.get('rx') ry_attr = elem.get('ry') raw_rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0 raw_ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0 if rx_attr is not None and ry_attr is None: raw_ry = raw_rx elif ry_attr is not None and rx_attr is None: raw_rx = raw_ry cx_ = ctx_x(raw_cx, ctx) cy_ = ctx_y(raw_cy, ctx) rx = raw_rx * ctx.scale_x ry = raw_ry * ctx.scale_y if rx <= 0 or ry <= 0: return None x = cx_ - rx y = cy_ - ry w = rx * 2 h = ry * 2 fill_op = get_fill_opacity(elem, ctx) stroke_op = get_stroke_opacity(elem, ctx) fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) geom = preset_geom or '' transform = elem.get('transform') shape_id = _claim_element_shape_id(elem, ctx) if preset_geom is not None: xfrm = _shape_xfrm_from_preset_frame( elem, ctx, (raw_cx - raw_rx, raw_cy - raw_ry, raw_rx * 2, raw_ry * 2), (x, y, w, h), transform, ) else: xfrm = _shape_xfrm_from_svg_rect( ctx, raw_cx - raw_rx, raw_cy - raw_ry, raw_rx * 2, raw_ry * 2, x, y, w, h, transform, ) xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm return ShapeResult( xml=_wrap_geometry_object( elem, ctx, shape_id, f'Ellipse {shape_id}', off_x, off_y, ext_cx, ext_cy, geom, fill, stroke, xfrm_attr=xfrm_attr, ), bounds_emu=bounds_emu, ) # --------------------------------------------------------------------------- # nested sprite (template-import round-trip) # --------------------------------------------------------------------------- # Inverse of pptx_to_svg/pic_to_svg.py:101-113 — that path writes a cropped # DrawingML picture as an outer wrapping a unit-rectangle . # Without this converter, every cropped picture in a template-import SVG is # silently dropped on re-export. @dataclass(frozen=True) class NestedSvgCropSpec: """Validated transport fields for one imported cropped picture.""" image: ET.Element x: float y: float width: float height: float view_box_x: float view_box_y: float view_box_width: float view_box_height: float src_l: int src_t: int src_r: int src_b: int _NESTED_CROP_OUTER_ATTRIBUTES = frozenset({ 'clip-path', 'data-pptx-crop', 'data-pptx-editable', EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR, 'data-pptx-frame', 'data-pptx-layer', 'data-pptx-object', 'data-pptx-carrier', 'data-pptx-prst', 'data-pptx-shape-id', 'data-pptx-shape-name', 'data-pptx-shape-scope', 'id', 'overflow', 'preserveAspectRatio', 'transform', 'viewBox', 'x', 'y', 'width', 'height', }) _NESTED_CROP_IMAGE_ATTRIBUTES = frozenset({ 'clip-path', 'href', f'{{{XLINK_NS}}}href', 'opacity', 'preserveAspectRatio', 'x', 'y', 'width', 'height', }) _DRAWINGML_PERCENTAGE_MIN = -(2 ** 31) _DRAWINGML_PERCENTAGE_MAX = 2 ** 31 - 1 def _unsupported_nested_crop_attributes( elem: ET.Element, allowed: frozenset[str], ) -> list[str]: unsupported = [] for name in elem.attrib: if name in allowed: continue unsupported.append(name.rsplit('}', 1)[-1]) return sorted(unsupported) def parse_project_nested_svg_crop(elem: ET.Element) -> NestedSvgCropSpec: """Parse the closed nested-SVG transport written by ``pptx_to_svg``.""" if elem.tag != f'{{{SVG_NS}}}svg': raise ValueError('expected an SVG-namespace nested crop wrapper') unsupported = _unsupported_nested_crop_attributes( elem, _NESTED_CROP_OUTER_ATTRIBUTES, ) if unsupported: raise ValueError( 'nested crop has unsupported attribute(s): ' + ', '.join(unsupported) ) crop_marker = elem.get('data-pptx-crop') overflow = elem.get('overflow') if overflow is not None and overflow != 'hidden': raise ValueError( 'nested crop overflow must be exactly "hidden" when present' ) if elem.text and elem.text.strip(): raise ValueError( 'nested crop cannot contain non-whitespace character data' ) children = list(elem) if ( len(children) != 1 or children[0].tag != f'{{{SVG_NS}}}image' ): raise ValueError( 'nested is reserved for imported picture crops; expected ' 'exactly one direct SVG-namespace child' ) image_elem = children[0] if image_elem.tail and image_elem.tail.strip(): raise ValueError( 'nested crop cannot contain non-whitespace character data' ) if list(image_elem) or (image_elem.text and image_elem.text.strip()): raise ValueError('nested crop must be an empty element') unsupported = _unsupported_nested_crop_attributes( image_elem, _NESTED_CROP_IMAGE_ATTRIBUTES, ) if unsupported: raise ValueError( 'nested crop has unsupported attribute(s): ' + ', '.join(unsupported) ) outer_clip_path = elem.get('clip-path') inner_clip_path = image_elem.get('clip-path') if outer_clip_path is not None and inner_clip_path is not None: raise ValueError( 'nested crop clip-path must occur on either the outer or ' 'the inner , not both' ) clip_path = inner_clip_path or outer_clip_path if crop_marker is not None and crop_marker != '1': raise ValueError('nested crop data-pptx-crop must be exactly "1"') if clip_path is None: if crop_marker is not None: raise ValueError( 'nested crop data-pptx-crop="1" requires clip-path' ) elif clip_path.strip().lower() == 'none': raise ValueError('nested crop clip-path cannot be "none"') elif crop_marker != '1': raise ValueError( 'nested crop clip-path requires data-pptx-crop="1"' ) if inner_clip_path is not None and overflow != 'hidden': raise ValueError( 'nested crop with inner clip-path requires ' 'overflow="hidden" on the outer ' ) try: _project_image_href(image_elem) except ValueError as exc: raise ValueError(f'nested crop {exc}') from exc required_outer = ( 'x', 'y', 'width', 'height', 'viewBox', 'preserveAspectRatio', ) missing = [name for name in required_outer if elem.get(name) is None] if missing: raise ValueError( 'nested crop requires explicit x, y, width, height, ' 'viewBox, and preserveAspectRatio="none"; missing ' + ', '.join(missing) ) if elem.get('preserveAspectRatio') != 'none': raise ValueError( 'nested crop preserveAspectRatio must be exactly "none"' ) frame_values: dict[str, float] = {} for name in ('x', 'y', 'width', 'height'): raw = elem.get(name) assert raw is not None try: frame_values[name] = parse_project_geometry_length(raw, name) except ValueError as exc: raise ValueError( f'nested crop {name}={raw!r}: {exc}' ) from exc if frame_values['width'] <= 0 or frame_values['height'] <= 0: raise ValueError('nested crop width and height must be positive') view_box = elem.get('viewBox') or '' view_box_tokens = view_box.strip().split() if ( len(view_box_tokens) != 4 or any( not is_canonical_project_geometry_length(token) for token in view_box_tokens ) ): raise ValueError( 'nested crop viewBox must contain four finite unitless ordinary ' 'decimals separated by whitespace' ) vb_x, vb_y, vb_w, vb_h = ( parse_project_geometry_length(token, 'x') for token in view_box_tokens ) if vb_w <= 0 or vb_h <= 0: raise ValueError('nested crop viewBox width and height must be positive') src_l = round(vb_x * 100000) src_t = round(vb_y * 100000) src_r = round((1.0 - vb_x - vb_w) * 100000) src_b = round((1.0 - vb_y - vb_h) * 100000) src_values = (src_l, src_t, src_r, src_b) if ( any( value < _DRAWINGML_PERCENTAGE_MIN or value > _DRAWINGML_PERCENTAGE_MAX for value in src_values ) or src_l + src_r >= 100000 or src_t + src_b >= 100000 ): raise ValueError( 'nested crop viewBox cannot be represented as a DrawingML ' 'srcRect with a positive visible region within the signed ' 'percentage range' ) if not any(src_values): raise ValueError( 'nested crop viewBox="0 0 1 1" is redundant; use a plain ' ) required_image_values = { 'x': '0', 'y': '0', 'width': '1', 'height': '1', 'preserveAspectRatio': 'none', } invalid_image_values = [ f'{name}={image_elem.get(name)!r}' for name, expected in required_image_values.items() if image_elem.get(name) != expected ] if invalid_image_values: raise ValueError( 'nested crop must use x="0", y="0", width="1", ' 'height="1", and preserveAspectRatio="none"; got ' + ', '.join(invalid_image_values) ) return NestedSvgCropSpec( image=image_elem, x=frame_values['x'], y=frame_values['y'], width=frame_values['width'], height=frame_values['height'], view_box_x=vb_x, view_box_y=vb_y, view_box_width=vb_w, view_box_height=vb_h, src_l=src_l, src_t=src_t, src_r=src_r, src_b=src_b, ) def project_nested_svg_crop_errors(root: ET.Element) -> list[str]: """Return contract errors for every nested SVG transport wrapper.""" errors: list[str] = [] parent_by_id = { id(child): parent for parent in root.iter() for child in list(parent) } for elem in root.iter(): if elem is root or elem.tag.rsplit('}', 1)[-1] != 'svg': continue elem_id = (elem.get('id') or '').strip() label = f'' if elem_id else '' ancestor = parent_by_id.get(id(elem)) invalid_ancestor: ET.Element | None = None while ancestor is not None and ancestor is not root: if ( ancestor.tag != f'{{{SVG_NS}}}g' or ancestor.get('data-pptx-part') is not None ): invalid_ancestor = ancestor break ancestor = parent_by_id.get(id(ancestor)) if invalid_ancestor is not None: errors.append( f'{label} invalid imported crop wrapper: visual ancestor chain ' 'may contain only ordinary elements' ) continue try: parse_project_nested_svg_crop(elem) except ValueError as exc: errors.append(f'{label} invalid imported crop wrapper: {exc}') return sorted(errors) def _resolve_nested_svg_clip_geometry( crop: NestedSvgCropSpec, image_elem: ET.Element, ctx: ConvertContext, ) -> str: """Resolve a preview-safe inner-image clip into picture geometry.""" default = '' clip_id = resolve_url_id(image_elem.get('clip-path', '')) if not clip_id or clip_id not in ctx.defs: return default clip_elem = ctx.defs[clip_id] shape = next( ( child for child in clip_elem if child.tag.rsplit('}', 1)[-1] in {'circle', 'ellipse', 'rect', 'path', 'polygon'} ), None, ) if shape is None: return default if ( clip_elem.get('clipPathUnits', 'userSpaceOnUse') != 'userSpaceOnUse' ): return _resolve_clip_geometry( image_elem, ctx, crop.x, crop.y, crop.width, crop.height, ) shape_tag = shape.tag.rsplit('}', 1)[-1] if shape_tag == 'rect': style_values = parse_inline_style(shape.get('style')) rx_raw = style_values.get('rx') or shape.get('rx') ry_raw = style_values.get('ry') or shape.get('ry') rx = ( parse_project_geometry_length(rx_raw, 'rx') if rx_raw is not None else None ) ry = ( parse_project_geometry_length(ry_raw, 'ry') if ry_raw is not None else None ) if rx is None and ry is not None: rx = ry elif ry is None and rx is not None: ry = rx rx = rx or 0.0 ry = ry or 0.0 if rx <= 0 and ry <= 0: return default physical_rx = rx * crop.width / crop.view_box_width physical_ry = ry * crop.height / crop.view_box_height radius = (physical_rx + physical_ry) / 2.0 shorter = min(crop.width, crop.height) if shorter <= 0: return default adj = int(min(radius / (shorter / 2.0), 1.0) * 50000) return ( f'' f'' f'' ) return _resolve_clip_geometry( image_elem, ctx, crop.view_box_x, crop.view_box_y, crop.view_box_width, crop.view_box_height, ) def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult: """Convert a nested sprite-crop wrapper to a DrawingML picture. Pattern produced by pptx_to_svg:: The viewBox crops the unit-rectangle inner image; that crop is mapped to a DrawingML so PowerPoint can re-crop / "Reset Picture". """ crop = parse_project_nested_svg_crop(elem) image_elem = crop.image source = load_project_image_source(image_elem, ctx.svg_dir) svg_x = crop.x svg_y = crop.y svg_w = crop.width svg_h = crop.height if ctx.use_transform_matrix: x = svg_x y = svg_y w = svg_w h = svg_h else: x = ctx_x(svg_x, ctx) y = ctx_y(svg_y, ctx) w = ctx_w(svg_w, ctx) h = ctx_h(svg_h, ctx) src_rect_xml = ( f'' ) img_format = source.img_format img_data = source.img_data transform = elem.get('transform') rendered_w, rendered_h = _picture_rendered_frame_size( ctx, svg_x, svg_y, svg_w, svg_h, x, y, w, h, transform, ) img_data, img_format = _optimize_image_for_pptx( ctx, img_data, img_format, rendered_w, rendered_h, visible_source_fraction=( 1.0 - (crop.src_l + crop.src_r) / 100000.0, 1.0 - (crop.src_t + crop.src_b) / 100000.0, ), ) r_id = _register_image_media(ctx, img_format, img_data) shape_id = _claim_element_shape_id(elem, ctx) xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect( ctx, svg_x, svg_y, svg_w, svg_h, x, y, w, h, transform, ) if image_elem.get('clip-path') is not None: clip_geom = _resolve_nested_svg_clip_geometry(crop, image_elem, ctx) else: clip_geom = _resolve_clip_geometry( elem, ctx, svg_x, svg_y, svg_w, svg_h, ) effect_xml = '' filter_id = get_effective_filter_id(elem, ctx) if filter_id and filter_id in ctx.defs: effect_xml = build_effect_xml( ctx.defs[filter_id], get_element_opacity(elem, ctx), ) blip_xml = _build_image_blip_xml( r_id, get_element_opacity(image_elem, ctx), ) return ShapeResult(xml=f''' {blip_xml} {src_rect_xml} {clip_geom} {effect_xml} ''', bounds_emu=bounds_emu)