Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/pptx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -72,6 +73,7 @@
ChartPart,
CorePropertiesPart,
ImagePart,
SvgImagePart,
MediaPart,
SlidePart,
SlideLayoutPart,
Expand Down
1 change: 1 addition & 0 deletions src/pptx/opc/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
2 changes: 2 additions & 0 deletions src/pptx/opc/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/pptx/oxml/ns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/pptx/oxml/shapes/groupshape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions src/pptx/oxml/shapes/picture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<p:pic>` 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 `<p:pic>` 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,
Expand Down Expand Up @@ -211,6 +233,68 @@ def _pic_tmpl(cls):
"</p:pic>" % nsdecls("a", "p", "r")
)

@classmethod
def _svg_pic_ph_tmpl(cls):
return (
"<p:pic %s>\n"
" <p:nvPicPr>\n"
' <p:cNvPr id="%%d" name="%%s" descr="%%s"/>\n'
" <p:cNvPicPr>\n"
' <a:picLocks noGrp="1" noChangeAspect="1"/>\n'
" </p:cNvPicPr>\n"
" <p:nvPr/>\n"
" </p:nvPicPr>\n"
" <p:blipFill>\n"
' <a:blip r:embed="%%s">\n'
" <a:extLst>\n"
' <a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}">\n'
' <asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="%%s"/>\n'
" </a:ext>\n"
" </a:extLst>\n"
" </a:blip>\n"
" <a:stretch>\n"
" <a:fillRect/>\n"
" </a:stretch>\n"
" </p:blipFill>\n"
" <p:spPr/>\n"
"</p:pic>" % nsdecls("p", "a", "r")
)

@classmethod
def _svg_pic_tmpl(cls):
return (
"<p:pic %s>\n"
" <p:nvPicPr>\n"
' <p:cNvPr id="%%d" name="%%s" descr="%%s"/>\n'
" <p:cNvPicPr>\n"
' <a:picLocks noChangeAspect="1"/>\n'
" </p:cNvPicPr>\n"
" <p:nvPr/>\n"
" </p:nvPicPr>\n"
" <p:blipFill>\n"
' <a:blip r:embed="%%s">\n'
" <a:extLst>\n"
' <a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}">\n'
' <asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="%%s"/>\n'
" </a:ext>\n"
" </a:extLst>\n"
" </a:blip>\n"
" <a:stretch>\n"
" <a:fillRect/>\n"
" </a:stretch>\n"
" </p:blipFill>\n"
" <p:spPr>\n"
" <a:xfrm>\n"
' <a:off x="%%d" y="%%d"/>\n'
' <a:ext cx="%%d" cy="%%d"/>\n'
" </a:xfrm>\n"
' <a:prstGeom prst="rect">\n'
" <a:avLst/>\n"
" </a:prstGeom>\n"
" </p:spPr>\n"
"</p:pic>" % nsdecls("a", "p", "r")
)

@classmethod
def _pic_video_tmpl(cls):
return (
Expand Down
161 changes: 160 additions & 1 deletion src/pptx/parts/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}'")
2 changes: 1 addition & 1 deletion src/pptx/shapes/picture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading