Skip to content

Commit 15eb959

Browse files
committed
feat: add SVG image support
1 parent 278b47b commit 15eb959

10 files changed

Lines changed: 283 additions & 8 deletions

File tree

src/pptx/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pptx.opc.package import PartFactory
1212
from pptx.parts.chart import ChartPart
1313
from pptx.parts.coreprops import CorePropertiesPart
14-
from pptx.parts.image import ImagePart
14+
from pptx.parts.image import ImagePart, SvgImagePart
1515
from pptx.parts.media import MediaPart
1616
from pptx.parts.presentation import PresentationPart
1717
from pptx.parts.slide import (
@@ -49,6 +49,7 @@
4949
CT.JPEG: ImagePart,
5050
CT.MS_PHOTO: ImagePart,
5151
CT.PNG: ImagePart,
52+
CT.SVG: SvgImagePart,
5253
CT.TIFF: ImagePart,
5354
CT.X_EMF: ImagePart,
5455
CT.X_WMF: ImagePart,
@@ -72,6 +73,7 @@
7273
ChartPart,
7374
CorePropertiesPart,
7475
ImagePart,
76+
SvgImagePart,
7577
MediaPart,
7678
SlidePart,
7779
SlideLayoutPart,

src/pptx/opc/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ class CONTENT_TYPE:
137137
"application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml"
138138
)
139139
SML_USER_NAMES = "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml"
140+
SVG = "image/svg+xml"
140141
SML_VOLATILE_DEPENDENCIES = (
141142
"application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml"
142143
)

src/pptx/opc/spec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
("mpg", CT.MPG),
1919
("png", CT.PNG),
2020
("rels", CT.OPC_RELATIONSHIPS),
21+
("svg", CT.SVG),
2122
("tif", CT.TIFF),
2223
("tiff", CT.TIFF),
2324
("vid", CT.VIDEO),
@@ -37,6 +38,7 @@
3738
"jpeg": CT.JPEG,
3839
"jpg": CT.JPEG,
3940
"png": CT.PNG,
41+
"svg": CT.SVG,
4042
"tif": CT.TIFF,
4143
"tiff": CT.TIFF,
4244
"wdp": CT.MS_PHOTO,

src/pptx/oxml/ns.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# -- Maps namespace prefix to namespace name for all known PowerPoint XML namespaces --
77
_nsmap = {
88
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
9+
"asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main",
910
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
1011
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
1112
"ct": "http://schemas.openxmlformats.org/package/2006/content-types",

src/pptx/oxml/shapes/groupshape.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ def add_pic(
9494
self.insert_element_before(pic, "p:extLst")
9595
return pic
9696

97+
def add_svg_pic(
98+
self, id_: int, name: str, desc: str, rId: str, x: int, y: int, cx: int, cy: int
99+
) -> CT_Picture:
100+
"""Append a `p:pic` shape containing native SVG markup."""
101+
pic = CT_Picture.new_svg_pic(id_, name, desc, rId, x, y, cx, cy)
102+
self.insert_element_before(pic, "p:extLst")
103+
return pic
104+
97105
def add_placeholder(
98106
self, id_: int, name: str, ph_type: PP_PLACEHOLDER, orient: str, sz: str, idx: int
99107
) -> CT_Shape:

src/pptx/oxml/shapes/picture.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ def blip_rId(self) -> str | None:
3636
return blip.rEmbed
3737
return None
3838

39+
@property
40+
def svg_rId(self) -> str | None:
41+
"""Value of `p:blipFill/a:blip/a:extLst/a:ext/asvg:svgBlip/@r:embed`."""
42+
blip = self.blipFill.blip
43+
if blip is None:
44+
return None
45+
46+
embeds = blip.xpath("./a:extLst/a:ext/asvg:svgBlip/@r:embed")
47+
return cast(str | None, embeds[0] if embeds else None)
48+
3949
def crop_to_fit(self, image_size, view_size):
4050
"""
4151
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):
6575
"""
6676
return parse_xml(cls._pic_ph_tmpl() % (id_, name, desc, rId))
6777

78+
@classmethod
79+
def new_svg_ph_pic(cls, id_, name, desc, rId):
80+
"""Return a new `p:pic` placeholder element populated with SVG image markup."""
81+
return parse_xml(cls._svg_pic_ph_tmpl() % (id_, name, escape(desc), rId, rId))
82+
6883
@classmethod
6984
def new_pic(cls, shape_id, name, desc, rId, x, y, cx, cy):
7085
"""Return new `<p:pic>` element tree configured with supplied parameters."""
7186
return parse_xml(cls._pic_tmpl() % (shape_id, name, escape(desc), rId, x, y, cx, cy))
7287

88+
@classmethod
89+
def new_svg_pic(cls, shape_id, name, desc, rId, x, y, cx, cy):
90+
"""Return new `<p:pic>` element tree configured with native SVG markup."""
91+
return parse_xml(
92+
cls._svg_pic_tmpl() % (shape_id, name, escape(desc), rId, rId, x, y, cx, cy)
93+
)
94+
7395
@classmethod
7496
def new_video_pic(
7597
cls,
@@ -211,6 +233,68 @@ def _pic_tmpl(cls):
211233
"</p:pic>" % nsdecls("a", "p", "r")
212234
)
213235

236+
@classmethod
237+
def _svg_pic_ph_tmpl(cls):
238+
return (
239+
"<p:pic %s>\n"
240+
" <p:nvPicPr>\n"
241+
' <p:cNvPr id="%%d" name="%%s" descr="%%s"/>\n'
242+
" <p:cNvPicPr>\n"
243+
' <a:picLocks noGrp="1" noChangeAspect="1"/>\n'
244+
" </p:cNvPicPr>\n"
245+
" <p:nvPr/>\n"
246+
" </p:nvPicPr>\n"
247+
" <p:blipFill>\n"
248+
' <a:blip r:embed="%%s">\n'
249+
" <a:extLst>\n"
250+
' <a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}">\n'
251+
' <asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="%%s"/>\n'
252+
" </a:ext>\n"
253+
" </a:extLst>\n"
254+
" </a:blip>\n"
255+
" <a:stretch>\n"
256+
" <a:fillRect/>\n"
257+
" </a:stretch>\n"
258+
" </p:blipFill>\n"
259+
" <p:spPr/>\n"
260+
"</p:pic>" % nsdecls("p", "a", "r")
261+
)
262+
263+
@classmethod
264+
def _svg_pic_tmpl(cls):
265+
return (
266+
"<p:pic %s>\n"
267+
" <p:nvPicPr>\n"
268+
' <p:cNvPr id="%%d" name="%%s" descr="%%s"/>\n'
269+
" <p:cNvPicPr>\n"
270+
' <a:picLocks noChangeAspect="1"/>\n'
271+
" </p:cNvPicPr>\n"
272+
" <p:nvPr/>\n"
273+
" </p:nvPicPr>\n"
274+
" <p:blipFill>\n"
275+
' <a:blip r:embed="%%s">\n'
276+
" <a:extLst>\n"
277+
' <a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}">\n'
278+
' <asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="%%s"/>\n'
279+
" </a:ext>\n"
280+
" </a:extLst>\n"
281+
" </a:blip>\n"
282+
" <a:stretch>\n"
283+
" <a:fillRect/>\n"
284+
" </a:stretch>\n"
285+
" </p:blipFill>\n"
286+
" <p:spPr>\n"
287+
" <a:xfrm>\n"
288+
' <a:off x="%%d" y="%%d"/>\n'
289+
' <a:ext cx="%%d" cy="%%d"/>\n'
290+
" </a:xfrm>\n"
291+
' <a:prstGeom prst="rect">\n'
292+
" <a:avLst/>\n"
293+
" </a:prstGeom>\n"
294+
" </p:spPr>\n"
295+
"</p:pic>" % nsdecls("a", "p", "r")
296+
)
297+
214298
@classmethod
215299
def _pic_video_tmpl(cls):
216300
return (

src/pptx/parts/image.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
import hashlib
66
import io
77
import os
8+
import re
89
from typing import IO, TYPE_CHECKING, Any, cast
10+
from xml.etree import ElementTree
911

1012
from PIL import Image as PIL_Image
1113

14+
from pptx.opc.constants import CONTENT_TYPE as CT
1215
from pptx.opc.package import Part
1316
from pptx.opc.spec import image_content_types
1417
from pptx.util import Emu, lazyproperty
@@ -19,6 +22,13 @@
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+
2232
class 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}'")

src/pptx/shapes/picture.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def image(self):
184184
185185
Provides access to the properties and bytes of the image in this picture shape.
186186
"""
187-
slide_part, rId = self.part, self._pic.blip_rId
187+
slide_part, rId = self.part, self._pic.svg_rId or self._pic.blip_rId
188188
if rId is None:
189189
raise ValueError("no embedded image")
190190
return slide_part.get_image(rId)

0 commit comments

Comments
 (0)