55import hashlib
66import io
77import os
8+ import re
89from typing import IO , TYPE_CHECKING , Any , cast
10+ from xml .etree import ElementTree
911
1012from PIL import Image as PIL_Image
1113
14+ from pptx .opc .constants import CONTENT_TYPE as CT
1215from pptx .opc .package import Part
1316from pptx .opc .spec import image_content_types
1417from pptx .util import Emu , lazyproperty
1922 from pptx .util import Length
2023
2124
25+ _SVG_NS = "http://www.w3.org/2000/svg"
26+ _SVG_LENGTH_RE = re .compile (
27+ r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]*)\s*$"
28+ )
29+ _SVG_PX_PER_INCH = 96.0
30+
31+
2232class ImagePart (Part ):
2333 """An image part.
2434
@@ -43,7 +53,8 @@ def new(cls, package: Package, image: Image) -> ImagePart:
4353
4454 `image` is an |Image| object.
4555 """
46- return cls (
56+ part_cls = SvgImagePart if isinstance (image , Svg ) else cls
57+ return part_cls (
4758 package .next_image_partname (image .ext ),
4859 image .content_type ,
4960 package ,
@@ -150,6 +161,8 @@ def __init__(self, blob: bytes, filename: str | None):
150161 @classmethod
151162 def from_blob (cls , blob : bytes , filename : str | None = None ) -> Image :
152163 """Return a new |Image| object loaded from the image binary in `blob`."""
164+ if _is_svg (blob , filename ):
165+ return Svg (blob , filename )
153166 return cls (blob , filename )
154167
155168 @classmethod
@@ -273,3 +286,149 @@ def _pil_props(self) -> tuple[str | None, tuple[int, int], tuple[int, int] | Non
273286 )
274287 stream .close ()
275288 return (format , (width_px , height_px ), dpi )
289+
290+
291+ class SvgImagePart (ImagePart ):
292+ """Image part subtype for native SVG images."""
293+
294+ @property
295+ def image (self ) -> Svg :
296+ """A |Svg| object containing the SVG in this image part."""
297+ return Svg (self ._blob , self .desc )
298+
299+
300+ class Svg (Image ):
301+ """Immutable value object representing an SVG image."""
302+
303+ @lazyproperty
304+ def content_type (self ) -> str :
305+ """MIME-type of this image."""
306+ return CT .SVG
307+
308+ @lazyproperty
309+ def dpi (self ) -> tuple [int , int ]:
310+ """Return the effective DPI used for SVG CSS pixel sizing."""
311+ return (int (_SVG_PX_PER_INCH ), int (_SVG_PX_PER_INCH ))
312+
313+ @lazyproperty
314+ def ext (self ) -> str :
315+ """Canonical file extension for this image."""
316+ return "svg"
317+
318+ @property
319+ def _format (self ) -> str :
320+ """Pseudo-format string for API parity with raster images."""
321+ return "SVG"
322+
323+ @lazyproperty
324+ def size (self ) -> tuple [int , int ]:
325+ """A (width, height) 2-tuple specifying the SVG viewport in CSS pixels."""
326+ return self ._px_size
327+
328+ @lazyproperty
329+ def _px_size (self ) -> tuple [int , int ]:
330+ """A (width, height) 2-tuple representing the SVG viewport in CSS pixels."""
331+ width_px , height_px = _svg_viewport_px_size (self ._root )
332+ return int (round (width_px )), int (round (height_px ))
333+
334+ @lazyproperty
335+ def _root (self ) -> ElementTree .Element :
336+ """Root XML element for this SVG image."""
337+ root = ElementTree .fromstring (self ._blob )
338+ if _local_name (root .tag ) != "svg" :
339+ raise ValueError ("image blob is not an SVG document" )
340+ return root
341+
342+
343+ def _is_svg (blob : bytes , filename : str | None ) -> bool :
344+ """True when `blob` appears to contain an SVG document."""
345+ if filename is not None and os .path .splitext (filename )[1 ].lower () == ".svg" :
346+ return True
347+
348+ stripped = blob .lstrip ()
349+ if not stripped .startswith (b"<" ):
350+ return False
351+
352+ try :
353+ root = ElementTree .fromstring (blob )
354+ except ElementTree .ParseError :
355+ return False
356+
357+ return _local_name (root .tag ) == "svg"
358+
359+
360+ def _local_name (tag : str ) -> str :
361+ """Return the local-name portion of an XML tag."""
362+ return tag .rsplit ("}" , 1 )[- 1 ]
363+
364+
365+ def _svg_viewbox (svg : ElementTree .Element ) -> tuple [float , float ] | None :
366+ """Return the SVG viewBox width and height when available."""
367+ view_box = svg .get ("viewBox" )
368+ if view_box is None :
369+ return None
370+
371+ parts = view_box .replace ("," , " " ).split ()
372+ if len (parts ) != 4 :
373+ raise ValueError ("SVG viewBox must contain four numeric values" )
374+
375+ _ , _ , width , height = (float (part ) for part in parts )
376+ if width <= 0 or height <= 0 :
377+ raise ValueError ("SVG viewBox dimensions must be greater than zero" )
378+ return width , height
379+
380+
381+ def _svg_viewport_px_size (svg : ElementTree .Element ) -> tuple [float , float ]:
382+ """Return the SVG viewport width and height expressed in CSS pixels."""
383+ viewbox = _svg_viewbox (svg )
384+ width_px = _svg_length_to_px (svg .get ("width" ))
385+ height_px = _svg_length_to_px (svg .get ("height" ))
386+
387+ if width_px is None and height_px is None and viewbox is None :
388+ return 300.0 , 150.0
389+
390+ if viewbox is not None :
391+ viewbox_width , viewbox_height = viewbox
392+ if width_px is None and height_px is None :
393+ return viewbox_width , viewbox_height
394+ if width_px is None :
395+ return height_px * viewbox_width / viewbox_height , height_px
396+ if height_px is None :
397+ return width_px , width_px * viewbox_height / viewbox_width
398+
399+ if width_px is None or height_px is None :
400+ raise ValueError ("SVG width and height must both be specified unless viewBox is present" )
401+ if width_px <= 0 or height_px <= 0 :
402+ raise ValueError ("SVG dimensions must be greater than zero" )
403+
404+ return width_px , height_px
405+
406+
407+ def _svg_length_to_px (length : str | None ) -> float | None :
408+ """Convert an SVG length value into CSS pixels."""
409+ if length is None :
410+ return None
411+
412+ match = _SVG_LENGTH_RE .match (length )
413+ if match is None :
414+ raise ValueError (f"unsupported SVG length value '{ length } '" )
415+
416+ magnitude = float (match .group (1 ))
417+ unit = match .group (2 ).lower () or "px"
418+
419+ if unit == "%" :
420+ return None
421+ if unit == "px" :
422+ return magnitude
423+ if unit == "in" :
424+ return magnitude * _SVG_PX_PER_INCH
425+ if unit == "cm" :
426+ return magnitude * _SVG_PX_PER_INCH / 2.54
427+ if unit == "mm" :
428+ return magnitude * _SVG_PX_PER_INCH / 25.4
429+ if unit == "pt" :
430+ return magnitude * _SVG_PX_PER_INCH / 72.0
431+ if unit == "pc" :
432+ return magnitude * _SVG_PX_PER_INCH / 6.0
433+
434+ raise ValueError (f"unsupported SVG length unit '{ unit } '" )
0 commit comments