diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c5ec60..e2b1da4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,30 @@ snapshot commit; the development history before it is summarised here.
## Unreleased
+- SVG pictures (production feedback round 3, I1): `slide.add_picture` takes an SVG and
+ stores it as PowerPoint 2016 and later store an inserted SVG, measured in PowerPoint for
+ Mac 16: the PNG in `a:blip`, the SVG behind its `asvg:svgBlip` extension (`xmlns:asvg` on
+ the element), a `ppt/media/imageN.svg` part (`image/svg+xml`) behind an ordinary image
+ relationship, the picture named "Graphic N" with its aspect unlocked. The PNG fallback is
+ `fallback=` or the SVG drawn at the placed size and `fallback_dpi` (300) by pptx2svg (an
+ `ImportError` names `pptx-agent[png]` without it); the native size is the SVG's width and
+ height, else its viewBox, at 96 dpi. The SVG is parsed as untrusted data first (no DTD,
+ no entities, at most 16 MiB), stored once, and kept as given. `picture.vector` reads it,
+ `picture.set_vector(svg | None)` attaches, replaces or removes it (one undo step), and
+ `picture.replace_image(png, svg=...)` (or an SVG `image`) keeps both images in step. The
+ outline marks the picture `picture svg`, the geometry `image.svg`. `ppt_add_picture`
+ takes `image/svg+xml` blobs, to insert and to replace, and makes the fallback. In
+ PowerPoint the result opens unprompted, draws the vector, and offers the Graphics Format
+ tab (Graphics Fill, Convert to Shape).
+- Align and distribute in the tools (round 3, A5): `ppt_arrange` actions `align` (`edge`,
+ `to`: `shapes`, `slide` or `content_area`) and `distribute` (`axis`, `within`: `shapes`
+ or `content_area`, optional `gap`) on its targets, through `pptx_agent.edit.arrange`; the
+ content area is `Slide.content_area`. Each is one undo step and returns the boxes. For
+ house rules that require equal spacing (GUIDANCE.md). The every-definition guard rises
+ to 11,400 (11,309 estimated, +196), the core unchanged; ppt_arrange is now sent
+ non-strict (eight optional parameters) and ppt_add_table strict in its place. Golden
+ p16: the Halden approach slide, four phases with SVG icons spaced across the content
+ area, 7 calls.
- Chart formatting (production feedback round 3, G1-G4; ooxml-edit 0.15.0): a chart's axis
scale (a 1-5 maturity axis by 1, categories top-down for a ranked bar chart), gridlines,
series and point colours, lines and markers, a column series drawn as a line (target
diff --git a/docs/USAGE.md b/docs/USAGE.md
index fd6e588..1e41e8c 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -23,7 +23,7 @@ see [Units](#units-emu-everywhere-pt-for-points) below.
| Tables | **fully editable**: cell text and formatting, fills, borders, rows and columns, sizes, merges; cells by row and column label |
| Charts | **fully editable**: values, categories, series, titles, axis titles, legend; series by name and points by category label; the cached data and the embedded workbook (what Edit Data opens) always change together |
| SmartArt | node text, adding and removing nodes |
-| Shapes | position, size, rotation, flips, preset geometry and adjustments, fills and outlines in theme colours, z-order, duplicate, delete, groups; new autoshapes, text boxes, tables, pictures and connectors that stay attached; a picture's native size, its image replaced |
+| Shapes | position, size, rotation, flips, preset geometry and adjustments, fills and outlines in theme colours, z-order, duplicate, delete, groups; new autoshapes, text boxes, tables, pictures (SVG too, with a PNG fallback) and connectors that stay attached; a picture's native size, its image replaced; align and distribute |
| Slides | add from a layout, duplicate, reorder, delete; titles, and a slide by its title; a layout's placeholders; draft whole slides from Markdown |
| Decks | new from nothing or from a template; title, author, language, slide size; the theme's colours and fonts, read and set; which colours do which job, and their tints |
| Checks | `validate()` (what PowerPoint would repair or refuse), `overflows()` and `text_fit()` (what will not fit, as PowerPoint draws it), `collisions()` (text over text, lines through text), `measure_text()` (before building) |
@@ -729,7 +729,13 @@ Positions in the tools are the model's own, in points. The layout library is sti
code -- `pptx_agent.edit.arrange` (align, distribute), `pptx_agent.edit.layout` (stack,
column, grid, place_labels), `pptx_agent.edit.scales` and `Slide.copy_shapes` -- but its
tools (`ppt_layout`, `ppt_scale`, `ppt_copy`, `ppt_align`) were removed after trial 3, where no
-model called them. `ppt_design_facts` reports the palette, colour-coded sets and legends,
+model called them. Align and distribute came back as two `ppt_arrange` actions for
+applications whose house rules require equal spacing (round 3): `align` with `edge` (left,
+center, right, top, middle, bottom) and `to` (`shapes`, the default, `slide` or
+`content_area`), `distribute` with `axis` and `within` (`shapes`, between the outermost
+targets, or `content_area`) and an optional fixed `gap`. The content area is
+`Slide.content_area`: the layout's body placeholders, else the band under the title. Each
+is one undo step and returns the boxes. `ppt_design_facts` reports the palette, colour-coded sets and legends,
empty regions, alignment, shape vocabulary, text sizes and lines over text -- facts, no
verdicts (`Slide.design_facts`).
@@ -843,6 +849,35 @@ states none). `replace_image` keeps the frame by default; `keep="height"` or `"w
the other side follow the new image's proportions, `"none"` places it at its pixel size, and
`anchor` is the point of the frame that stays put.
+### SVG pictures: icons that stay sharp
+
+```python
+from pptx_agent import Document
+
+check = (b'')
+deck = Document.new()
+slide = deck.add_slide("Blank")
+icon = slide.add_picture(check, 914400, 914400) # its own size: 48 px at 96 dpi
+print(icon.width, icon.vector == check, icon.image_size.width) # -> 457200 True 150
+green = check.replace(b"#1F5FAD", b"#2E8B57")
+icon.replace_image(green) # a recoloured icon, its PNG made
+icon.set_vector(None) # a plain bitmap again
+print(icon.vector) # -> None
+```
+
+`slide.add_picture` takes an SVG (bytes or a `.svg` path) and stores it as PowerPoint 2016
+and later store an inserted SVG: the SVG, which PowerPoint draws sharp at any size and can
+recolour, and a PNG fallback for consumers that cannot draw SVG. The fallback is `fallback=`
+when given, else the SVG drawn at the placed size and `fallback_dpi` (300) by pptx2svg, which
+needs `pip install 'pptx-agent[png]'`. Without a size the picture takes the SVG's `width` and
+`height`, else its `viewBox`, at 96 dpi. The SVG is parsed as untrusted data (no DTD, no
+entities, at most 16 MiB) before it is stored, and an identical one is stored once.
+`replace_image(png, svg=...)` swaps both images at once; `set_vector` attaches, replaces or
+removes the SVG and leaves the PNG. `ppt_add_picture` takes an `image/svg+xml` blob, to insert
+or to replace, and makes the fallback itself; `ppt_read_slides` marks the picture `svg` (the
+outline's ``, the geometry's `image.svg`).
+
### Full-state SVG
```python
diff --git a/src/pptx_agent/edit/document.py b/src/pptx_agent/edit/document.py
index 982b3ff..874fa5b 100644
--- a/src/pptx_agent/edit/document.py
+++ b/src/pptx_agent/edit/document.py
@@ -74,6 +74,7 @@
from .ids import ShapeId, SlideShapeIndex, cnv_pr, read_stamp, write_stamp
from .comments import CommentOps, drop_comments
from . import creating as _creating
+from . import svgpicture as _svgpicture
from .creating import Adjustments, ShapeFactory
if TYPE_CHECKING: # pragma: no cover
@@ -1375,7 +1376,9 @@ def image_size(self) -> "ImageSize | None":
return ImageSize(pixels[0], pixels[1], image_dpi(data))
def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str = "frame",
- anchor: str = "top_left") -> "Shape":
+ anchor: str = "top_left",
+ svg: "bytes | str | os.PathLike[str] | None" = None,
+ fallback_dpi: float = 300) -> "Shape":
"""Show a different image in this picture, keeping its effects and outline.
``keep`` says what of the frame stays: ``"frame"`` (the default) its position and
@@ -1387,6 +1390,11 @@ def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str =
``"top_right"``, ``"left"``, ``"center"``, ``"right"``, ``"bottom_left"``,
``"bottom"`` or ``"bottom_right"``.
+ An SVG ``image`` makes this an SVG picture, as :meth:`Slide.add_picture` makes one:
+ its PNG fallback drawn at the new frame's size and ``fallback_dpi``. A raster
+ ``image`` with ``svg`` (bytes or a path) gives both images at once -- a recoloured
+ icon and its PNG. Otherwise the picture keeps no SVG.
+
The crop (``a:srcRect``) and any SVG alternative belonged to the old image and are
dropped. The old image's relationship is removed, and its media part too unless
something else in the package still shows it. One undo step.
@@ -1395,6 +1403,7 @@ def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str =
picture.replace_image("new-logo.png") # same frame
logo.replace_image("new-logo.png", keep="height", anchor="top_right")
+ icon.replace_image(recoloured_png, svg=recoloured_svg)
"""
if self._blip() is None:
raise ValueError(f"{self.id}: a {self.kind} has no image to replace")
@@ -1403,9 +1412,20 @@ def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str =
if anchor not in _ANCHORS:
raise ValueError(f"anchor is one of {sorted(_ANCHORS)}")
data, suffix = _read_image(image)
+ vector = None
+ if _svgpicture.looks_like_svg(data, suffix):
+ if svg is not None:
+ raise ValueError("image is an SVG already: give svg with a raster image")
+ vector, data, suffix = data, None, None
+ elif svg is not None:
+ vector, svg_suffix = _read_image(svg)
+ if not _svgpicture.looks_like_svg(vector, svg_suffix):
+ raise ValueError("svg is not an SVG document")
+ root = _svgpicture.parse(vector) if vector is not None else None
+ native = _svgpicture.native_size(root) if root is not None else None
+ pixels = native if native is not None else (image_size(data) if data else None)
frame = None
if keep != "frame":
- pixels = image_size(data)
if pixels is None or not all(pixels):
raise ValueError("cannot read this image's size; keep='frame' needs none")
left, top, width, height = self.left, self.top, self.width, self.height
@@ -1416,10 +1436,16 @@ def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str =
elif keep == "width":
new_w, new_h = width, round(width * pixels[1] / pixels[0])
else:
- new_w, new_h = (dimension * EMU_PER_INCH // 96 for dimension in pixels)
+ new_w, new_h = (round(dimension * EMU_PER_INCH / 96) for dimension in pixels)
fx, fy = _ANCHORS[anchor]
frame = (round(left + (width - new_w) * fx), round(top + (height - new_h) * fy),
new_w, new_h)
+ if data is None:
+ size = frame[2:] if frame is not None else (self.width, self.height)
+ if None in size:
+ raise ValueError(f"{self.id}: the picture has no size to draw the SVG at")
+ data = _svgpicture.rasterise(vector, size[0], size[1], fallback_dpi,
+ self._slide.document.font_dirs)
package = self._slide.document.package
with self._slide.document.batch():
self._before_change()
@@ -1429,13 +1455,10 @@ def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str =
assert blip is not None
blip.set(qn("r:embed"), rel_id)
blip.attrib.pop(qn("r:link"), None)
- extensions = blip.find(qn("a:extLst"))
- if extensions is not None:
- for extension in list(extensions):
- if extension.get("uri") == _SVG_BLIP_EXT_URI:
- remove(extension)
- if len(extensions) == 0:
- remove(extensions)
+ _svgpicture.drop(blip)
+ if vector is not None:
+ _svgpicture.attach(blip, package.add_relationship(
+ self._slide.part_path, REL_IMAGE, _svgpicture.store(package, vector)))
crop = blip.getparent().find(qn("a:srcRect"))
if crop is not None:
remove(crop)
@@ -1445,6 +1468,56 @@ def replace_image(self, image: "bytes | str | os.PathLike[str]", *, keep: str =
self._set_extent(cx=frame[2], cy=frame[3])
return self
+ @property
+ def vector(self) -> bytes | None:
+ """The SVG an SVG picture shows (``asvg:svgBlip``), as stored; ``None`` for any
+ other shape, and for a picture with no SVG or whose SVG part is missing.
+
+ For example::
+
+ if picture.vector is not None:
+ icon = picture.vector.decode()
+ """
+ blip = self._blip()
+ found = _svgpicture.svg_blip(blip) if blip is not None else None
+ if found is None:
+ return None
+ part = self._slide.document.package.related_part(self._slide.part_path,
+ found.get(qn("r:embed")))
+ if part is None or not self._slide.document.package.has_part(part):
+ return None
+ return self._slide.document.package.read(part)
+
+ def set_vector(self, svg: "bytes | str | os.PathLike[str] | None") -> "Shape":
+ """Give this picture an SVG original (bytes or a path), replace it, or remove it
+ (``None``). The PNG it shows stays: it is what a consumer without SVG draws, so it
+ should be this SVG's picture (:meth:`replace_image` with ``svg=`` changes both).
+ The SVG is parsed as untrusted data first and stored once. One undo step.
+
+ For example::
+
+ picture.set_vector("icon.svg")
+ picture.set_vector(None) # a plain bitmap again
+ """
+ blip = self._blip()
+ if blip is None:
+ raise ValueError(f"{self.id}: a {self.kind} has no image to give an SVG")
+ vector = None
+ if svg is not None:
+ vector, suffix = _read_image(svg)
+ if not _svgpicture.looks_like_svg(vector, suffix):
+ raise ValueError("svg is not an SVG document")
+ _svgpicture.parse(vector)
+ package = self._slide.document.package
+ with self._slide.document.batch():
+ self._before_change()
+ _svgpicture.drop(blip)
+ if vector is not None:
+ _svgpicture.attach(blip, package.add_relationship(
+ self._slide.part_path, REL_IMAGE, _svgpicture.store(package, vector)))
+ self._after_change()
+ return self
+
def _blip(self) -> Element | None:
if local_name(self._element) != "pic":
return None
@@ -1917,19 +1990,46 @@ def move_to(self, index: int) -> "Slide":
def add_picture(self, image: "bytes | str | os.PathLike[str]", left: int = 0, top: int = 0,
width: int | None = None, height: int | None = None, *,
- name: str | None = None, description: str | None = None) -> Shape:
+ name: str | None = None, description: str | None = None,
+ fallback: "bytes | str | os.PathLike[str] | None" = None,
+ fallback_dpi: float = 300) -> Shape:
"""Insert a picture shape, frontmost; returns it.
- ``image`` is the image's bytes or a path (PNG, JPEG, GIF, BMP or TIFF). Without a size
- the picture is placed at its pixel size at 96 dpi; with only one of ``width`` and
+ ``image`` is the image's bytes or a path (PNG, JPEG, GIF, BMP, TIFF or SVG). Without a
+ size the picture is placed at its pixel size at 96 dpi; with only one of ``width`` and
``height`` the other keeps the aspect ratio. The image is stored once -- an identical
one already in the deck is reused -- and undo removes it again.
+ **An SVG** becomes an SVG picture as PowerPoint 2016 and later store one
+ (:mod:`pptx_agent.edit.svgpicture`): the SVG itself, which PowerPoint draws and can
+ recolour, and a PNG for consumers that cannot draw SVG. The PNG is ``fallback``
+ when given, else the SVG drawn at the placed size and ``fallback_dpi`` by pptx2svg
+ (``pip install 'pptx-agent[png]'``; an ``ImportError`` says so without it). Its
+ own size is the SVG's ``width`` and ``height``, else its ``viewBox``, at 96 dpi. The
+ SVG is parsed as untrusted data (no DTD, no entities, at most 16 MiB) before it is
+ stored.
+
For example::
slide.add_picture("logo.png", left=914400, top=457200, width=1828800)
+ slide.add_picture("icon.svg", left=914400, top=457200, width=457200)
"""
data, suffix = _read_image(image)
+ vector = None
+ if _svgpicture.looks_like_svg(data, suffix):
+ vector = data
+ width, height = _svgpicture.size_emu(_svgpicture.parse(vector), width, height)
+ if fallback is None:
+ data = _svgpicture.rasterise(vector, width, height, fallback_dpi,
+ self.document.font_dirs)
+ suffix = None
+ else:
+ data, suffix = _read_image(fallback)
+ if _svgpicture.looks_like_svg(data, suffix):
+ raise ValueError("fallback is the bitmap shown where SVG is not drawn "
+ "(PNG, JPEG...), not an SVG")
+ elif fallback is not None:
+ raise ValueError("fallback is for an SVG picture; this image is not an SVG")
pixels = image_size(data)
if width is None or height is None:
if pixels is None or not all(pixels):
@@ -1950,18 +2050,25 @@ def add_picture(self, image: "bytes | str | os.PathLike[str]", left: int = 0, to
identifier = self._next_shape_id()
picture = make("p:pic")
nv = make("p:nvPicPr")
+ # PowerPoint names an inserted SVG "Graphic N" and leaves its aspect unlocked.
properties = make("p:cNvPr", id=str(identifier),
- name=name or f"Picture {identifier - 1}")
+ name=name or f"{'Graphic' if vector else 'Picture'} "
+ f"{identifier - 1}")
if description:
properties.set("descr", description)
nv.append(properties)
locks = make("p:cNvPicPr")
- locks.append(make("a:picLocks", noChangeAspect="1"))
+ locks.append(make("a:picLocks") if vector else make("a:picLocks",
+ noChangeAspect="1"))
nv.append(locks)
nv.append(make("p:nvPr"))
picture.append(nv)
blip_fill = make("p:blipFill")
- blip_fill.append(make("a:blip", r__embed=rel_id))
+ blip = make("a:blip", r__embed=rel_id)
+ if vector is not None:
+ _svgpicture.attach(blip, package.add_relationship(
+ self.part_path, REL_IMAGE, _svgpicture.store(package, vector)))
+ blip_fill.append(blip)
stretch = make("a:stretch")
stretch.append(make("a:fillRect"))
blip_fill.append(stretch)
@@ -3582,10 +3689,6 @@ def aspect(self) -> float:
"center": (0.5, 0.5), "right": (1, 0.5), "bottom_left": (0, 1),
"bottom": (0.5, 1), "bottom_right": (1, 1)}
-#: ``a:ext`` URI of an SVG alternative to a bitmap blip (``asvg:svgBlip``).
-_SVG_BLIP_EXT_URI = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"
-
-
def _read_image(image: "bytes | str | os.PathLike[str]") -> tuple[bytes, str | None]:
"""An image's bytes, and the extension its path gave, if it came from a file."""
if isinstance(image, (str, os.PathLike)):
diff --git a/src/pptx_agent/edit/svgpicture.py b/src/pptx_agent/edit/svgpicture.py
new file mode 100644
index 0000000..8f33889
--- /dev/null
+++ b/src/pptx_agent/edit/svgpicture.py
@@ -0,0 +1,240 @@
+"""SVG pictures, as PowerPoint 2016 and later store an inserted SVG.
+
+A picture keeps two images: the PNG every consumer can draw, in ``a:blip@r:embed``, and
+the SVG it came from, behind an extension of that blip::
+
+
+
+
+
+
+
+
+
+Measured in PowerPoint for Mac 16 (2026-10-11, an SVG inserted and the deck saved): the
+SVG is a ``ppt/media/imageN.svg`` part (``Default Extension="svg"
+ContentType="image/svg+xml"``) behind an ordinary ``.../relationships/image``
+relationship -- not the ``2017/06/relationships/svgImage`` type, which is read but not
+written; ``xmlns:asvg`` is declared on the ``svgBlip``; the picture is named "Graphic N"
+with an empty ``a:picLocks``; the PNG is the SVG drawn at twice its own size (192 dpi).
+
+The SVG is untrusted data: it is parsed before it is stored, with no document type,
+no entities and no network (:func:`pptx_agent.fullstate.safe.parse_untrusted`, as
+:meth:`Document.apply_svg ` does), and the bytes stored are
+the ones given.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING, Sequence
+
+if TYPE_CHECKING: # pragma: no cover
+ from ..oxml.package import PresentationPackage
+
+#: ``a:ext`` URI of an SVG alternative to a bitmap blip.
+SVG_BLIP_EXT_URI = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"
+#: The ``asvg:`` namespace of ``svgBlip``.
+ASVG_NS = "http://schemas.microsoft.com/office/drawing/2016/SVG/main"
+SVG_CONTENT_TYPE = "image/svg+xml"
+SVG_NS = "http://www.w3.org/2000/svg"
+#: The largest SVG stored as a picture, in bytes.
+SVG_LIMIT = 16 * 1024 * 1024
+#: The longest side of a fallback PNG made here, in pixels.
+FALLBACK_MAX_PX = 4096
+
+_EMU_PER_INCH = 914400
+#: CSS units per inch: an SVG's lengths, its user units being pixels at 96 dpi.
+_PER_INCH = {"px": 96.0, "": 96.0, "pt": 72.0, "pc": 6.0, "in": 1.0, "cm": 2.54,
+ "mm": 25.4}
+_LENGTH = re.compile(r"^\s*([+]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-z]*)\s*$")
+_SVG_TAG = re.compile(rb"