"""DrawingML -> SVG conversion. Reverse of svg_to_pptx convert_image. DrawingML structure: ...... (optional crop) (default: fill the shape) (usually rect; can be other) Strategy: - Prefer the editable SVG relationship in asvg:svgBlip when Office also stores a raster compatibility preview on a:blip; retain the raster as fallback. - Default (no srcRect, plain stretch) -> a single filling the box, preserveAspectRatio="none". - With srcRect, or with a single oversized tile that covers the frame -> wrap the in a nested in the unit rectangle [0,1] x [0,1], with overflow hidden so cropping is expressed identically in browsers and PowerPoint. - Repeating tile fills still use the legacy plain-image fallback; a repeated pattern cannot be represented by the project's native picture-crop subset. - Image bytes are written through the result; the slide assembler decides the href format (external file vs base64). """ from __future__ import annotations import base64 import hashlib import io import math import mimetypes import shutil import subprocess import tempfile from dataclasses import dataclass, field from pathlib import Path from xml.etree import ElementTree as ET try: from PIL import Image, ImageEnhance except ImportError: # pragma: no cover - optional visual enhancement dependency Image = None ImageEnhance = None from .emu_units import NS, Xfrm, emu_to_px, fmt_num, format_ooxml_alpha from .ooxml_loader import OoxmlPackage, PartRef, blip_embed_relationship_ids @dataclass(frozen=True) class PictureDiagnostic: """Recoverable loss while converting one DrawingML picture.""" code: str message: str fallback: str @dataclass class PictureResult: """Resolved picture: SVG element string + extracted media bytes.""" svg: str = "" # Map of {filename: bytes} that the assembler should emit alongside # the SVG. Filename is the basename inside the package's media dir. media: dict[str, bytes] = field(default_factory=dict) diagnostics: tuple[PictureDiagnostic, ...] = () class MediaResolutionError(RuntimeError): """Raised when a PPTX media relationship cannot be reproduced as SVG.""" def convert_blip_fill( blip_fill_elem: ET.Element, xfrm: Xfrm, slide_part: PartRef, pkg: OoxmlPackage, *, media_subdir: str = "assets", embed_inline: bool = False, asset_name_map: dict[str, str] | None = None, strict: bool = False, ) -> PictureResult: """Convert an element to SVG . Handles image fill for both: - (standard picture elements) - (shape with image fill, e.g. Canva exports) """ blip = blip_fill_elem.find("a:blip", NS) if blip is None: return PictureResult() relationship_ids = blip_embed_relationship_ids(blip) linked_rid = blip.attrib.get(f"{{{NS['r']}}}link") if not relationship_ids: if linked_rid: raise MediaResolutionError( "Linked image relationships are not supported; embed the image in PowerPoint first" ) return PictureResult() target: str | None = None img_bytes: bytes | None = None failures: list[str] = [] for rel_id in relationship_ids: candidate = slide_part.resolve_rel(rel_id) if not candidate: failures.append(f"{rel_id}: unresolved") continue candidate_bytes = pkg.read_media(candidate) if candidate_bytes is None: failures.append(f"{rel_id}: missing {candidate}") continue if Path(candidate).suffix.lower() == ".svg" and not _is_valid_svg_media(candidate_bytes): failures.append(f"{rel_id}: invalid SVG media {candidate}") continue target = candidate img_bytes = candidate_bytes break if target is None or img_bytes is None: details = "; ".join(failures) raise MediaResolutionError( f"No embedded image relationship can be read in {slide_part.path}: {details}" ) filename = (asset_name_map or {}).get(target, pkg.media_filename(target)) filename, img_bytes = _normalize_office_media(filename, img_bytes) tile_source_bytes = img_bytes diagnostics = list(_unsupported_blip_effect_diagnostics(blip)) filename, img_bytes, effect_diagnostics = _apply_blip_image_effects( filename, img_bytes, blip, ) diagnostics.extend(effect_diagnostics) opacity_attr, opacity_diagnostics = _blip_opacity_attr(blip) diagnostics.extend(opacity_diagnostics) if strict and diagnostics: details = "; ".join(item.message for item in diagnostics) raise ValueError(f"Cannot reproduce DrawingML picture effects: {details}") href = _build_href(filename, img_bytes, media_subdir, embed_inline) # srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%). src_rect = blip_fill_elem.find("a:srcRect", NS) crop = _parse_src_rect(src_rect) # A tile larger than its frame is visually a crop, not a stretch. Preserve # that common PowerPoint background construction through the same nested # SVG transport used for srcRect. True repeated patterns retain the legacy # plain-image fallback because native export has no registered tile subset. tile = blip_fill_elem.find("a:tile", NS) if crop is None and tile is not None: crop = _single_cover_tile_crop( tile, blip_fill_elem, xfrm, tile_source_bytes, ) if crop is None: # Plain unclipped image svg = ( f'' ) else: # Crop expressed as a unit-rectangle viewBox on a nested . vb_l, vb_t, vb_w, vb_h = crop svg = ( f'' f'' f"" ) media: dict[str, bytes] = {} if not embed_inline: media[filename] = img_bytes return PictureResult( svg=svg, media=media, diagnostics=tuple(diagnostics), ) def convert_picture( pic_elem: ET.Element, xfrm: Xfrm, slide_part: PartRef, pkg: OoxmlPackage, *, media_subdir: str = "assets", embed_inline: bool = False, asset_name_map: dict[str, str] | None = None, strict: bool = False, ) -> PictureResult: """Translate to SVG (or nested + for cropping).""" blip_fill = pic_elem.find("p:blipFill", NS) if blip_fill is None: return PictureResult() return convert_blip_fill( blip_fill, xfrm, slide_part, pkg, media_subdir=media_subdir, embed_inline=embed_inline, asset_name_map=asset_name_map, strict=strict, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _blip_opacity_attr( blip: ET.Element, ) -> tuple[str, tuple[PictureDiagnostic, ...]]: """Translate DrawingML fixed image alpha to an SVG opacity attribute.""" alpha_effects = blip.findall("a:alphaModFix", NS) if not alpha_effects: return "", () if len(alpha_effects) > 1: return "", ( _effect_diagnostic( "duplicate a:alphaModFix effects cannot be reproduced safely" ), ) alpha = alpha_effects[0] try: opacity = float(alpha.attrib.get("amt", "100000")) / 100000.0 except ValueError: return "", ( _effect_diagnostic( f"invalid a:alphaModFix amt={alpha.attrib.get('amt')!r}" ), ) if not math.isfinite(opacity) or not 0.0 <= opacity <= 1.0: return "", ( _effect_diagnostic( f"out-of-range a:alphaModFix amt={alpha.attrib.get('amt')!r}" ), ) if opacity >= 1.0: return "", () return f' opacity="{format_ooxml_alpha(opacity)}"', () def _effect_diagnostic(message: str) -> PictureDiagnostic: return PictureDiagnostic( code="image-effect-omitted", message=message, fallback="retain the source image and omit only this image effect", ) def _unsupported_blip_effect_diagnostics( blip: ET.Element, ) -> tuple[PictureDiagnostic, ...]: """Report direct a:blip effects outside the implemented subset.""" supported_tags = { f"{{{NS['a']}}}lum", f"{{{NS['a']}}}alphaModFix", f"{{{NS['a']}}}extLst", } unsupported = sorted({ child.tag.rsplit("}", 1)[-1] for child in blip if child.tag not in supported_tags }) diagnostics = ( [ _effect_diagnostic( "unsupported direct a:blip effect(s): " + ", ".join(f"a:{name}" for name in unsupported) ) ] if unsupported else [] ) if len(blip.findall("a:lum", NS)) > 1: diagnostics.append( _effect_diagnostic( "duplicate a:lum effects cannot be reproduced safely" ) ) return tuple(diagnostics) _OFFICE_VECTOR_EXTS = {".emf", ".wmf"} def _is_valid_svg_media(data: bytes) -> bool: """Return whether one media part is a namespaced SVG document.""" try: root = ET.fromstring(data) except ET.ParseError: return False return root.tag == "{http://www.w3.org/2000/svg}svg" def _normalize_office_media(filename: str, img_bytes: bytes) -> tuple[str, bytes]: """Convert Office-only vector image formats to browser-renderable PNG. PPTX can contain EMF/WMF assets that PowerPoint renders natively but SVG viewers generally do not. Keep the original asset in the manifest layer; the SVG view uses a PNG preview when the local system can make one. """ suffix = Path(filename).suffix.lower() if suffix not in _OFFICE_VECTOR_EXTS: return filename, img_bytes converted = _convert_office_vector_to_png(filename, img_bytes) if converted is None: return filename, img_bytes stem = Path(filename).stem return f"{stem}_preview.png", converted def _convert_office_vector_to_png(filename: str, img_bytes: bytes) -> bytes | None: magick = shutil.which("magick") if not magick: return None suffix = Path(filename).suffix.lower() or ".bin" with tempfile.TemporaryDirectory() as tmp: tmp_dir = Path(tmp) src = tmp_dir / f"source{suffix}" dst = tmp_dir / "preview.png" src.write_bytes(img_bytes) try: subprocess.run( [magick, str(src), str(dst)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except (OSError, subprocess.CalledProcessError): return None if not dst.exists(): return None return dst.read_bytes() def _parse_src_rect(elem: ET.Element | None) -> tuple[float, float, float, float] | None: """Convert to (x, y, w, h) in unit space.""" if elem is None: return None if not (elem.attrib.keys() & {"l", "t", "r", "b"}): return None l = _pct_attr(elem, "l") t = _pct_attr(elem, "t") r = _pct_attr(elem, "r") b = _pct_attr(elem, "b") # All zero -> equivalent to no crop if l == 0 and t == 0 and r == 0 and b == 0: return None vb_x = l vb_y = t vb_w = max(0.0, 1.0 - l - r) vb_h = max(0.0, 1.0 - t - b) if vb_w <= 0 or vb_h <= 0: return None return vb_x, vb_y, vb_w, vb_h def _single_cover_tile_crop( tile: ET.Element, blip_fill: ET.Element, xfrm: Xfrm, img_bytes: bytes, ) -> tuple[float, float, float, float] | None: """Map one oversized DrawingML tile to a unit-image crop window. The closed SVG/PPTX crop transport cannot express a repeating pattern. It can, however, reproduce a tile when the aligned first tile alone covers the complete frame. This is how PowerPoint commonly stores full-page bitmap backgrounds without distorting their aspect ratio. """ if Image is None or xfrm.w <= 0 or xfrm.h <= 0: return None if tile.attrib.get("flip", "none") != "none": return None natural_size = _image_size_at_96_dpi(img_bytes, blip_fill) if natural_size is None: return None natural_w, natural_h = natural_size scale_x = _pct_attr_default(tile, "sx", 1.0) scale_y = _pct_attr_default(tile, "sy", 1.0) tile_w = natural_w * scale_x tile_h = natural_h * scale_y if tile_w <= 0 or tile_h <= 0: return None align_x, align_y = _tile_alignment(tile.attrib.get("algn", "tl")) tile_x = ( xfrm.x + (xfrm.w - tile_w) * align_x + emu_to_px(tile.attrib.get("tx")) ) tile_y = ( xfrm.y + (xfrm.h - tile_h) * align_y + emu_to_px(tile.attrib.get("ty")) ) tolerance = 1e-4 if ( tile_x > xfrm.x + tolerance or tile_y > xfrm.y + tolerance or tile_x + tile_w < xfrm.x + xfrm.w - tolerance or tile_y + tile_h < xfrm.y + xfrm.h - tolerance ): return None crop = ( (xfrm.x - tile_x) / tile_w, (xfrm.y - tile_y) / tile_h, xfrm.w / tile_w, xfrm.h / tile_h, ) if all( abs(actual - expected) <= 1e-7 for actual, expected in zip(crop, (0.0, 0.0, 1.0, 1.0)) ): return None return crop def _image_size_at_96_dpi( img_bytes: bytes, blip_fill: ET.Element, ) -> tuple[float, float] | None: """Return the bitmap's physical size in the SVG canvas' 96-DPI pixels.""" try: with Image.open(io.BytesIO(img_bytes)) as image: pixel_w, pixel_h = image.size embedded_dpi = image.info.get("dpi") except (OSError, ValueError): return None configured_dpi = _positive_float(blip_fill.attrib.get("dpi")) if configured_dpi is not None: dpi_x = dpi_y = configured_dpi else: dpi_x, dpi_y = _dpi_pair(embedded_dpi) return pixel_w * 96.0 / dpi_x, pixel_h * 96.0 / dpi_y def _dpi_pair(value: object) -> tuple[float, float]: if isinstance(value, (tuple, list)) and len(value) >= 2: dpi_x = _positive_float(value[0]) or 96.0 dpi_y = _positive_float(value[1]) or 96.0 return dpi_x, dpi_y dpi = _positive_float(value) or 96.0 return dpi, dpi def _positive_float(value: object) -> float | None: try: parsed = float(value) except (TypeError, ValueError): return None return parsed if parsed > 0 else None def _pct_attr_default(elem: ET.Element, name: str, default: float) -> float: value = elem.attrib.get(name) if value is None: return default try: return float(value) / 100000.0 except ValueError: return default def _tile_alignment(value: str) -> tuple[float, float]: alignments = { "tl": (0.0, 0.0), "t": (0.5, 0.0), "tr": (1.0, 0.0), "l": (0.0, 0.5), "ctr": (0.5, 0.5), "r": (1.0, 0.5), "bl": (0.0, 1.0), "b": (0.5, 1.0), "br": (1.0, 1.0), } return alignments.get(value, alignments["tl"]) def _apply_blip_image_effects( filename: str, img_bytes: bytes, blip: ET.Element, ) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]: """Bake supported DrawingML blip effects into extracted image bytes. Brightness and contrast are pixel operations, not picture-shape shadow/glow effects, so preserve them in the extracted bitmap. """ lum_effects = blip.findall("a:lum", NS) if not lum_effects: return filename, img_bytes, () if len(lum_effects) > 1: return filename, img_bytes, () lum = lum_effects[0] values: dict[str, float | None] = {} for name in ("bright", "contrast"): raw_value = lum.attrib.get(name) if raw_value is None: values[name] = None continue try: value = float(raw_value) / 100000.0 except ValueError: return filename, img_bytes, ( _effect_diagnostic(f"invalid a:lum {name}={raw_value!r}"), ) if not math.isfinite(value) or not -1.0 <= value <= 1.0: return filename, img_bytes, ( _effect_diagnostic( f"out-of-range a:lum {name}={raw_value!r}" ), ) values[name] = value bright = values["bright"] contrast = values["contrast"] if bright is None and contrast is None: return filename, img_bytes, () if Image is None or ImageEnhance is None: return filename, img_bytes, ( _effect_diagnostic( "a:lum requires Pillow, but Pillow is unavailable" ), ) try: with Image.open(io.BytesIO(img_bytes)) as source_image: if getattr(source_image, "is_animated", False): return filename, img_bytes, ( _effect_diagnostic( "a:lum on an animated image would flatten its frames" ), ) output_format = ( source_image.format or _pil_format_from_filename(filename) ) image = source_image.copy() if image.mode not in ("RGB", "RGBA"): image = image.convert("RGBA" if "A" in image.getbands() else "RGB") if bright is not None: image = ImageEnhance.Brightness(image).enhance(max(0.0, 1.0 + bright)) if contrast is not None: image = ImageEnhance.Contrast(image).enhance(max(0.0, 1.0 + contrast)) out = io.BytesIO() save_format = output_format or "PNG" save_kwargs = {"quality": 95} if save_format.upper() in {"JPEG", "JPG"} else {} image.save(out, format=save_format, **save_kwargs) effect_key = f"lum-{bright}-{contrast}".encode("ascii") digest = hashlib.sha1(effect_key).hexdigest()[:8] return ( _effect_filename(filename, digest, save_format), out.getvalue(), (), ) except (KeyError, OSError, ValueError) as exc: return filename, img_bytes, ( _effect_diagnostic(f"a:lum could not be rendered: {exc}"), ) def _pil_format_from_filename(filename: str) -> str | None: ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if ext in {"jpg", "jpeg"}: return "JPEG" if ext == "png": return "PNG" if ext == "gif": return "GIF" if ext == "webp": return "WEBP" return None def _effect_filename(filename: str, digest: str, image_format: str) -> str: stem, sep, ext = filename.rpartition(".") if not sep: ext = (image_format or "png").lower() stem = filename if ext.lower() == "jpg": ext = "jpeg" return f"{stem}_fx_{digest}.{ext}" def _pct_attr(elem: ET.Element, name: str) -> float: val = elem.attrib.get(name) if val is None: return 0.0 try: return float(val) / 100000.0 except ValueError: return 0.0 def _build_href(filename: str, img_bytes: bytes, subdir: str, embed: bool) -> str: """Build an value (relative path or data URI). The path is relative to the SVG file's location. The slide assembler writes SVGs to /svg/, so media files in // resolve via a leading "../". """ if embed: mime = ( mimetypes.guess_type(filename)[0] or _sniff_mime(img_bytes) or "application/octet-stream" ) encoded = base64.b64encode(img_bytes).decode("ascii") return f"data:{mime};base64,{encoded}" rel = f"../{subdir}/{filename}" if subdir else f"../{filename}" return rel def _sniff_mime(data: bytes) -> str | None: """Best-effort MIME sniffing for embedded images.""" if data.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" if data.startswith(b"\xff\xd8\xff"): return "image/jpeg" if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"): return "image/gif" if data.startswith(b"