diff --git a/src/pptx/__init__.py b/src/pptx/__init__.py index fb5c2d7e4..e5cc415b6 100644 --- a/src/pptx/__init__.py +++ b/src/pptx/__init__.py @@ -11,7 +11,7 @@ from pptx.opc.package import PartFactory from pptx.parts.chart import ChartPart from pptx.parts.coreprops import CorePropertiesPart -from pptx.parts.image import ImagePart +from pptx.parts.image import ImagePart, SvgImagePart from pptx.parts.media import MediaPart from pptx.parts.presentation import PresentationPart from pptx.parts.slide import ( @@ -49,6 +49,7 @@ CT.JPEG: ImagePart, CT.MS_PHOTO: ImagePart, CT.PNG: ImagePart, + CT.SVG: SvgImagePart, CT.TIFF: ImagePart, CT.X_EMF: ImagePart, CT.X_WMF: ImagePart, @@ -72,6 +73,7 @@ ChartPart, CorePropertiesPart, ImagePart, + SvgImagePart, MediaPart, SlidePart, SlideLayoutPart, diff --git a/src/pptx/opc/constants.py b/src/pptx/opc/constants.py index e1b08a93a..18309b53e 100644 --- a/src/pptx/opc/constants.py +++ b/src/pptx/opc/constants.py @@ -137,6 +137,7 @@ class CONTENT_TYPE: "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml" ) SML_USER_NAMES = "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml" + SVG = "image/svg+xml" SML_VOLATILE_DEPENDENCIES = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml" ) diff --git a/src/pptx/opc/spec.py b/src/pptx/opc/spec.py index a83caf8bd..95774d5fd 100644 --- a/src/pptx/opc/spec.py +++ b/src/pptx/opc/spec.py @@ -18,6 +18,7 @@ ("mpg", CT.MPG), ("png", CT.PNG), ("rels", CT.OPC_RELATIONSHIPS), + ("svg", CT.SVG), ("tif", CT.TIFF), ("tiff", CT.TIFF), ("vid", CT.VIDEO), @@ -37,6 +38,7 @@ "jpeg": CT.JPEG, "jpg": CT.JPEG, "png": CT.PNG, + "svg": CT.SVG, "tif": CT.TIFF, "tiff": CT.TIFF, "wdp": CT.MS_PHOTO, diff --git a/src/pptx/oxml/ns.py b/src/pptx/oxml/ns.py index d900c33bf..4ce03c4b1 100644 --- a/src/pptx/oxml/ns.py +++ b/src/pptx/oxml/ns.py @@ -6,6 +6,7 @@ # -- Maps namespace prefix to namespace name for all known PowerPoint XML namespaces -- _nsmap = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", "ct": "http://schemas.openxmlformats.org/package/2006/content-types", diff --git a/src/pptx/oxml/shapes/groupshape.py b/src/pptx/oxml/shapes/groupshape.py index f62bc6662..e527d5b1b 100644 --- a/src/pptx/oxml/shapes/groupshape.py +++ b/src/pptx/oxml/shapes/groupshape.py @@ -94,6 +94,14 @@ def add_pic( self.insert_element_before(pic, "p:extLst") return pic + def add_svg_pic( + self, id_: int, name: str, desc: str, rId: str, x: int, y: int, cx: int, cy: int + ) -> CT_Picture: + """Append a `p:pic` shape containing native SVG markup.""" + pic = CT_Picture.new_svg_pic(id_, name, desc, rId, x, y, cx, cy) + self.insert_element_before(pic, "p:extLst") + return pic + def add_placeholder( self, id_: int, name: str, ph_type: PP_PLACEHOLDER, orient: str, sz: str, idx: int ) -> CT_Shape: diff --git a/src/pptx/oxml/shapes/picture.py b/src/pptx/oxml/shapes/picture.py index bacc97194..c67631baa 100644 --- a/src/pptx/oxml/shapes/picture.py +++ b/src/pptx/oxml/shapes/picture.py @@ -36,6 +36,16 @@ def blip_rId(self) -> str | None: return blip.rEmbed return None + @property + def svg_rId(self) -> str | None: + """Value of `p:blipFill/a:blip/a:extLst/a:ext/asvg:svgBlip/@r:embed`.""" + blip = self.blipFill.blip + if blip is None: + return None + + embeds = blip.xpath("./a:extLst/a:ext/asvg:svgBlip/@r:embed") + return cast(str | None, embeds[0] if embeds else None) + def crop_to_fit(self, image_size, view_size): """ Set cropping values in `p:blipFill/a:srcRect` such that an image of @@ -65,11 +75,23 @@ def new_ph_pic(cls, id_, name, desc, rId): """ return parse_xml(cls._pic_ph_tmpl() % (id_, name, desc, rId)) + @classmethod + def new_svg_ph_pic(cls, id_, name, desc, rId): + """Return a new `p:pic` placeholder element populated with SVG image markup.""" + return parse_xml(cls._svg_pic_ph_tmpl() % (id_, name, escape(desc), rId, rId)) + @classmethod def new_pic(cls, shape_id, name, desc, rId, x, y, cx, cy): """Return new `` element tree configured with supplied parameters.""" return parse_xml(cls._pic_tmpl() % (shape_id, name, escape(desc), rId, x, y, cx, cy)) + @classmethod + def new_svg_pic(cls, shape_id, name, desc, rId, x, y, cx, cy): + """Return new `` element tree configured with native SVG markup.""" + return parse_xml( + cls._svg_pic_tmpl() % (shape_id, name, escape(desc), rId, rId, x, y, cx, cy) + ) + @classmethod def new_video_pic( cls, @@ -211,6 +233,68 @@ def _pic_tmpl(cls): "" % nsdecls("a", "p", "r") ) + @classmethod + def _svg_pic_ph_tmpl(cls): + return ( + "\n" + " \n" + ' \n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + ' \n' + " \n" + ' \n' + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "" % nsdecls("p", "a", "r") + ) + + @classmethod + def _svg_pic_tmpl(cls): + return ( + "\n" + " \n" + ' \n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + ' \n' + " \n" + ' \n' + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ' \n' + " \n" + ' \n' + " \n" + " \n" + " \n" + "" % nsdecls("a", "p", "r") + ) + @classmethod def _pic_video_tmpl(cls): return ( diff --git a/src/pptx/parts/image.py b/src/pptx/parts/image.py index 9be5d02d6..81537bc64 100644 --- a/src/pptx/parts/image.py +++ b/src/pptx/parts/image.py @@ -5,10 +5,13 @@ import hashlib import io import os +import re from typing import IO, TYPE_CHECKING, Any, cast +from xml.etree import ElementTree from PIL import Image as PIL_Image +from pptx.opc.constants import CONTENT_TYPE as CT from pptx.opc.package import Part from pptx.opc.spec import image_content_types from pptx.util import Emu, lazyproperty @@ -19,6 +22,13 @@ from pptx.util import Length +_SVG_NS = "http://www.w3.org/2000/svg" +_SVG_LENGTH_RE = re.compile( + r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]*)\s*$" +) +_SVG_PX_PER_INCH = 96.0 + + class ImagePart(Part): """An image part. @@ -43,7 +53,8 @@ def new(cls, package: Package, image: Image) -> ImagePart: `image` is an |Image| object. """ - return cls( + part_cls = SvgImagePart if isinstance(image, Svg) else cls + return part_cls( package.next_image_partname(image.ext), image.content_type, package, @@ -150,6 +161,8 @@ def __init__(self, blob: bytes, filename: str | None): @classmethod def from_blob(cls, blob: bytes, filename: str | None = None) -> Image: """Return a new |Image| object loaded from the image binary in `blob`.""" + if _is_svg(blob, filename): + return Svg(blob, filename) return cls(blob, filename) @classmethod @@ -273,3 +286,149 @@ def _pil_props(self) -> tuple[str | None, tuple[int, int], tuple[int, int] | Non ) stream.close() return (format, (width_px, height_px), dpi) + + +class SvgImagePart(ImagePart): + """Image part subtype for native SVG images.""" + + @property + def image(self) -> Svg: + """A |Svg| object containing the SVG in this image part.""" + return Svg(self._blob, self.desc) + + +class Svg(Image): + """Immutable value object representing an SVG image.""" + + @lazyproperty + def content_type(self) -> str: + """MIME-type of this image.""" + return CT.SVG + + @lazyproperty + def dpi(self) -> tuple[int, int]: + """Return the effective DPI used for SVG CSS pixel sizing.""" + return (int(_SVG_PX_PER_INCH), int(_SVG_PX_PER_INCH)) + + @lazyproperty + def ext(self) -> str: + """Canonical file extension for this image.""" + return "svg" + + @property + def _format(self) -> str: + """Pseudo-format string for API parity with raster images.""" + return "SVG" + + @lazyproperty + def size(self) -> tuple[int, int]: + """A (width, height) 2-tuple specifying the SVG viewport in CSS pixels.""" + return self._px_size + + @lazyproperty + def _px_size(self) -> tuple[int, int]: + """A (width, height) 2-tuple representing the SVG viewport in CSS pixels.""" + width_px, height_px = _svg_viewport_px_size(self._root) + return int(round(width_px)), int(round(height_px)) + + @lazyproperty + def _root(self) -> ElementTree.Element: + """Root XML element for this SVG image.""" + root = ElementTree.fromstring(self._blob) + if _local_name(root.tag) != "svg": + raise ValueError("image blob is not an SVG document") + return root + + +def _is_svg(blob: bytes, filename: str | None) -> bool: + """True when `blob` appears to contain an SVG document.""" + if filename is not None and os.path.splitext(filename)[1].lower() == ".svg": + return True + + stripped = blob.lstrip() + if not stripped.startswith(b"<"): + return False + + try: + root = ElementTree.fromstring(blob) + except ElementTree.ParseError: + return False + + return _local_name(root.tag) == "svg" + + +def _local_name(tag: str) -> str: + """Return the local-name portion of an XML tag.""" + return tag.rsplit("}", 1)[-1] + + +def _svg_viewbox(svg: ElementTree.Element) -> tuple[float, float] | None: + """Return the SVG viewBox width and height when available.""" + view_box = svg.get("viewBox") + if view_box is None: + return None + + parts = view_box.replace(",", " ").split() + if len(parts) != 4: + raise ValueError("SVG viewBox must contain four numeric values") + + _, _, width, height = (float(part) for part in parts) + if width <= 0 or height <= 0: + raise ValueError("SVG viewBox dimensions must be greater than zero") + return width, height + + +def _svg_viewport_px_size(svg: ElementTree.Element) -> tuple[float, float]: + """Return the SVG viewport width and height expressed in CSS pixels.""" + viewbox = _svg_viewbox(svg) + width_px = _svg_length_to_px(svg.get("width")) + height_px = _svg_length_to_px(svg.get("height")) + + if width_px is None and height_px is None and viewbox is None: + return 300.0, 150.0 + + if viewbox is not None: + viewbox_width, viewbox_height = viewbox + if width_px is None and height_px is None: + return viewbox_width, viewbox_height + if width_px is None: + return height_px * viewbox_width / viewbox_height, height_px + if height_px is None: + return width_px, width_px * viewbox_height / viewbox_width + + if width_px is None or height_px is None: + raise ValueError("SVG width and height must both be specified unless viewBox is present") + if width_px <= 0 or height_px <= 0: + raise ValueError("SVG dimensions must be greater than zero") + + return width_px, height_px + + +def _svg_length_to_px(length: str | None) -> float | None: + """Convert an SVG length value into CSS pixels.""" + if length is None: + return None + + match = _SVG_LENGTH_RE.match(length) + if match is None: + raise ValueError(f"unsupported SVG length value '{length}'") + + magnitude = float(match.group(1)) + unit = match.group(2).lower() or "px" + + if unit == "%": + return None + if unit == "px": + return magnitude + if unit == "in": + return magnitude * _SVG_PX_PER_INCH + if unit == "cm": + return magnitude * _SVG_PX_PER_INCH / 2.54 + if unit == "mm": + return magnitude * _SVG_PX_PER_INCH / 25.4 + if unit == "pt": + return magnitude * _SVG_PX_PER_INCH / 72.0 + if unit == "pc": + return magnitude * _SVG_PX_PER_INCH / 6.0 + + raise ValueError(f"unsupported SVG length unit '{unit}'") diff --git a/src/pptx/shapes/picture.py b/src/pptx/shapes/picture.py index 59182860d..458ec517b 100644 --- a/src/pptx/shapes/picture.py +++ b/src/pptx/shapes/picture.py @@ -184,7 +184,7 @@ def image(self): Provides access to the properties and bytes of the image in this picture shape. """ - slide_part, rId = self.part, self._pic.blip_rId + slide_part, rId = self.part, self._pic.svg_rId or self._pic.blip_rId if rId is None: raise ValueError("no embedded image") return slide_part.get_image(rId) diff --git a/src/pptx/shapes/placeholder.py b/src/pptx/shapes/placeholder.py index c44837bef..d9d0551f5 100644 --- a/src/pptx/shapes/placeholder.py +++ b/src/pptx/shapes/placeholder.py @@ -331,20 +331,24 @@ def _new_placeholder_pic(self, image_file): having an `a:xfrm` element, allowing its extents to be inherited from its layout placeholder. """ - rId, desc, image_size = self._get_or_add_image(image_file) + image_part, rId, desc, image_size = self._get_or_add_image(image_file) shape_id, name = self.shape_id, self.name - pic = CT_Picture.new_ph_pic(shape_id, name, desc, rId) + pic = ( + CT_Picture.new_svg_ph_pic(shape_id, name, desc, rId) + if image_part.ext == "svg" + else CT_Picture.new_ph_pic(shape_id, name, desc, rId) + ) pic.crop_to_fit(image_size, (self.width, self.height)) return pic def _get_or_add_image(self, image_file): """ - Return an (rId, description, image_size) 3-tuple identifying the + Return an (image_part, rId, description, image_size) 4-tuple identifying the related image part containing *image_file* and describing the image. """ image_part, rId = self.part.get_or_add_image_part(image_file) desc, image_size = image_part.desc, image_part._px_size - return rId, desc, image_size + return image_part, rId, desc, image_size class PlaceholderGraphicFrame(GraphicFrame): diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index 29623f1f5..104efe851 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -372,6 +372,17 @@ def add_picture( self._recalculate_extents() return cast(Picture, self._shape_factory(pic)) + def add_svg( + self, + svg_file: str | IO[bytes], + left: Length, + top: Length, + width: Length | None = None, + height: Length | None = None, + ) -> Picture: + """Add picture shape displaying the SVG in `svg_file`.""" + return self.add_picture(svg_file, left, top, width, height) + def add_shape( self, autoshape_type_id: MSO_SHAPE, left: Length, top: Length, width: Length, height: Length ) -> Shape: @@ -483,7 +494,10 @@ def _add_pic_from_image_part( scaled_cx, scaled_cy = image_part.scale(cx, cy) name = "Picture %d" % (id_ - 1) desc = image_part.desc - pic = self._grpSp.add_pic(id_, name, desc, rId, x, y, scaled_cx, scaled_cy) + if image_part.ext == "svg": + pic = self._grpSp.add_svg_pic(id_, name, desc, rId, x, y, scaled_cx, scaled_cy) + else: + pic = self._grpSp.add_pic(id_, name, desc, rId, x, y, scaled_cx, scaled_cy) return pic def _add_sp(