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'' + 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"/]") + + +def looks_like_svg(data: bytes, suffix: str | None = None) -> bool: + """Whether ``data`` is meant as an SVG: a ``.svg`` path, or markup with an `` float | None: + if not value: + return None + match = _LENGTH.match(value) + if match is None or match.group(2) not in _PER_INCH: + return None # a percentage, em, or something unreadable + number = float(match.group(1)) + return number * 96.0 / _PER_INCH[match.group(2)] if number > 0 else None + + +def native_size(root) -> tuple[float, float] | None: + """``(width, height)`` in pixels at 96 dpi: the root's ``width`` and ``height`` (in px, + pt, pc, in, cm or mm), one of them with the ``viewBox``'s proportions, or the + ``viewBox`` alone -- what PowerPoint inserts an SVG at. ``None`` when it states none.""" + width, height = _length(root.get("width")), _length(root.get("height")) + box = None + numbers = re.split(r"[\s,]+", (root.get("viewBox") or "").strip()) + if len(numbers) == 4: + try: + box = tuple(float(n) for n in numbers) + except ValueError: + box = None + if box is not None and (box[2] <= 0 or box[3] <= 0): + box = None + if width and height: + return width, height + if box is not None: + if width: + return width, width * box[3] / box[2] + if height: + return height * box[2] / box[3], height + return box[2], box[3] + return None + + +def size_emu(root, width: int | None, height: int | None) -> tuple[int, int]: + """The frame for an SVG: the size given, the other side in the SVG's proportions, or + its :func:`native_size` at 96 dpi. A ``ValueError`` when the SVG states no size and + the call gives too little.""" + if width is not None and height is not None: + return int(width), int(height) + native = native_size(root) + if native is None: + raise ValueError("the SVG states no size (width, height or viewBox): pass width " + "and height") + natural_w = round(native[0] * _EMU_PER_INCH / 96) + natural_h = round(native[1] * _EMU_PER_INCH / 96) + if width is None and height is None: + return natural_w, natural_h + if width is None: + return round(height * natural_w / natural_h), int(height) + return int(width), round(width * natural_h / natural_w) + + +def rasterise(data: bytes, width_emu: int, height_emu: int, dpi: float = 300, + font_dirs: Sequence[str] | None = None) -> bytes: + """A PNG of the SVG at its placed size and ``dpi`` (its longer side at most + :data:`FALLBACK_MAX_PX`), drawn by pptx2svg's rasteriser; an ``ImportError`` naming + ``pptx-agent[png]`` without it.""" + if dpi <= 0: + raise ValueError("fallback_dpi must be above 0") + width = width_emu * dpi / _EMU_PER_INCH + height = height_emu * dpi / _EMU_PER_INCH + scale = min(1.0, FALLBACK_MAX_PX / max(width, height, 1)) + width, height = max(1, round(width * scale)), max(1, round(height * scale)) + message = ("an SVG picture needs a PNG fallback: install pptx-agent[png] " + "(pip install 'pptx-agent[png]'), or pass fallback= a PNG") + try: + from pptx2svg.png import RasterizerNotAvailable, svg_to_png + except ImportError as error: + raise ImportError(message) from error + text = data.decode("utf-8-sig", errors="strict") if _is_utf8(data) else None + if text is None: + raise ValueError("the SVG is not UTF-8 text") + # The rasteriser reads a size in pixels only (resvg-py refuses width="72pt"): the root + # gets its native size in pixels, or the placed one when it states none. + root = parse(data) + native = native_size(root) or (width, height) + if (root.get("width"), root.get("height")) != tuple(f"{v:g}" for v in native): + from lxml import etree + + root.set("width", f"{native[0]:g}") + root.set("height", f"{native[1]:g}") + text = etree.tostring(root, encoding="unicode") + try: + return svg_to_png(text, width=width, height=height, + check_glyphs=False, + **({"font_dirs": list(font_dirs)} if font_dirs is not None else {})) + except RasterizerNotAvailable as error: + raise ImportError(message) from error + + +def _is_utf8(data: bytes) -> bool: + try: + data.decode("utf-8-sig") + except UnicodeDecodeError: + return False + return True + + +def store(package: "PresentationPackage", data: bytes) -> str: + """Store an SVG under ``ppt/media/``, reusing an identical one already there; its part + name.""" + existing = package.find_part_with_bytes(data, "ppt/media") + if existing is not None and package.content_type(existing) == SVG_CONTENT_TYPE: + return existing + return package.add_part(package.unused_part_name("ppt/media/image{n}.svg"), data, + SVG_CONTENT_TYPE) + + +def svg_blip(blip): + """The ``asvg:svgBlip`` under a blip, else ``None``.""" + from ..oxml.xml import qn + + extensions = blip.find(qn("a:extLst")) + if extensions is None: + return None + for extension in extensions: + if extension.get("uri") == SVG_BLIP_EXT_URI: + found = extension.find(f"{{{ASVG_NS}}}svgBlip") + if found is not None: + return found + return None + + +def drop(blip) -> None: + """Remove a blip's SVG extension (and an ``a:extLst`` it leaves empty).""" + from ..oxml.xml import qn, remove + + extensions = blip.find(qn("a:extLst")) + if extensions is None: + return + for extension in list(extensions): + if extension.get("uri") == SVG_BLIP_EXT_URI: + remove(extension) + if len(extensions) == 0: + remove(extensions) + + +def attach(blip, rel_id: str) -> None: + """Point a blip's SVG extension at ``rel_id``, writing the extension as PowerPoint does + (``xmlns:asvg`` on the ``svgBlip``) when there is none.""" + from lxml import etree + + from ..oxml.xml import make, qn + + found = svg_blip(blip) + if found is not None: + found.set(qn("r:embed"), rel_id) + return + extensions = blip.find(qn("a:extLst")) + if extensions is None: + extensions = make("a:extLst") + blip.append(extensions) + extension = make("a:ext", uri=SVG_BLIP_EXT_URI) + extensions.append(extension) + element = etree.SubElement(extension, f"{{{ASVG_NS}}}svgBlip", nsmap={"asvg": ASVG_NS}) + element.set(qn("r:embed"), rel_id) + + +__all__ = ["ASVG_NS", "SVG_BLIP_EXT_URI", "SVG_CONTENT_TYPE", "SVG_LIMIT", "attach", "drop", + "looks_like_svg", "native_size", "parse", "rasterise", "size_emu", "store", + "svg_blip"] diff --git a/src/pptx_agent/outline/__init__.py b/src/pptx_agent/outline/__init__.py index 1c09056..7253a26 100644 --- a/src/pptx_agent/outline/__init__.py +++ b/src/pptx_agent/outline/__init__.py @@ -64,7 +64,8 @@ breaks are ``
``. As in docx-agent, its cells are named by the table's id: ``256.7/cell,``. A cell a merge covers is empty. * **A picture** is ``![alt text](source)``: the alt text is ``cNvPr@descr``, the source the - media part (``ppt/media/image1.png``), or a linked picture's address. + media part (``ppt/media/image1.png``), or a linked picture's address. An SVG picture's + comment says ``picture svg``, and its source is the PNG it shows. * **A chart** is one line: its type (``column``, ``bar``, ``line``, ``pie``...; a combination joined by ``+``), title, series and categories. **SmartArt** is a list of its node text, nested by level. diff --git a/src/pptx_agent/outline/draft.py b/src/pptx_agent/outline/draft.py index 80712bf..6703b8e 100644 --- a/src/pptx_agent/outline/draft.py +++ b/src/pptx_agent/outline/draft.py @@ -115,6 +115,8 @@ def _hint(text: str) -> Hint | None: hint.kind = word elif word == "empty": hint.empty = True + elif word == "svg": + pass # an SVG picture: its source is the PNG it shows elif word == "ph:" and k + 1 < len(words): kind = words[k + 1] idx = None diff --git a/src/pptx_agent/outline/read.py b/src/pptx_agent/outline/read.py index a503da2..a7dec71 100644 --- a/src/pptx_agent/outline/read.py +++ b/src/pptx_agent/outline/read.py @@ -269,7 +269,8 @@ def _shape_block(document: "Document", slide: "Slide", shape: "Shape", *, words = prefix + ([placeholder_label(placeholder)] if placeholder is not None else []) return " ".join(words), lines if kind == "pic": - return " ".join(prefix + ["picture"] + ([placeholder_label(placeholder)] + vector = ["svg"] if shape.vector is not None else [] + return " ".join(prefix + ["picture"] + vector + ([placeholder_label(placeholder)] if placeholder is not None else [])), \ [_picture(document, slide, shape)] if kind == "graphicFrame": diff --git a/src/pptx_agent/tools/GUIDANCE.md b/src/pptx_agent/tools/GUIDANCE.md index cbd9c70..8a5145c 100644 --- a/src/pptx_agent/tools/GUIDANCE.md +++ b/src/pptx_agent/tools/GUIDANCE.md @@ -153,3 +153,10 @@ system = toolbox.system_prompt(extra=HOUSE_RULES) - Put the same rules in the reviewer's brief, so editing and review judge alike. - Do not put house rules in tool descriptions or patch them into the libraries: the tools stay the same for every application, and a rule belongs to one. +- **A rule that requires equal spacing or alignment** -- tiles in a row, process steps, + icon and label pairs -- should name the call that guarantees it: "Space rows of like + shapes with `ppt_arrange` `distribute` (`within: content_area` to span it, or a `gap`) + and line them up with `align`." Without it the model places each shape by hand and + learns of uneven gaps only from `checks.layout` afterwards, a turn per row; models did + not call separate layout tools unprompted, which is why these are `ppt_arrange` actions + and the rule says when to use them. diff --git a/src/pptx_agent/tools/SUPPORTED.md b/src/pptx_agent/tools/SUPPORTED.md index 339599c..f75712f 100644 --- a/src/pptx_agent/tools/SUPPORTED.md +++ b/src/pptx_agent/tools/SUPPORTED.md @@ -28,7 +28,9 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)). | Position, size, rotation, flips, fill, gradient, outline, geometry | `ppt_set_shape` | only the fields given change | | Connectors that stay attached | `ppt_add_connector` (`straight`, `elbow`, `curved`) | glued to a shape's side, or between points; arrowheads | | Z-order, group, ungroup, duplicate, delete | `ppt_arrange` | | +| Align and distribute | `ppt_arrange` (`align`, `distribute`) | align an edge or centre to the targets' bounds, the slide or the content area; distribute with equal gaps between the outermost targets or across the content area, or with a fixed gap; one undo step, the boxes returned | | Pictures: insert, replace | `ppt_add_picture` | from an image blob; replacing keeps the frame, height or width | +| SVG pictures | `ppt_add_picture` with an `image/svg+xml` blob | stored as PowerPoint 2016 and later store an inserted SVG: the vector, which PowerPoint can recolour, and a PNG fallback made for it; insert and replace | | Tables: new; cells, rows, columns, merges, widths, heights, fill, borders; style and Table Design flags | `ppt_add_table`, `ppt_edit_table` (`set_style`, `set_flags`) | cells by row and column or by their labels; a style by name or id, the deck's own (a template's) or any of PowerPoint's 74 built-in styles; header row, total row, first and last column, banded rows and columns | | Charts: new from data; read; values, categories, series, titles, axis titles, legend, data labels, gap width, axis scales, gridlines, series and point colours and markers, a column series as a line, chart text, point labels | `edit_chart` | column, stacked column, bar, stacked bar, line, pie, scatter, radar (lines), filled radar, bubble, as PowerPoint inserts each; point labels from the workbook as PowerPoint's Value From Cells; the embedded workbook changes with the chart | | SmartArt: node text, adding and removing nodes | `edit_smartart` | diagrams already in the deck; PowerPoint lays the diagram out again on opening | @@ -43,7 +45,8 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)). - **Animations and transitions.** - **SmartArt creation:** build the graphic with shapes and connectors (`ppt_add_shape`, `ppt_add_connector`); existing SmartArt can be edited. -- **Icons** (PowerPoint's icon library). +- **Icons** (PowerPoint's icon library): no icon library is provided. An icon the + application brings as SVG can be inserted (SVG pictures, above). - **Video and audio.** - **Sections** (grouping slides into named sections). - **Hyperlinks:** no links between slides, and no listing or editing of links. (A web address diff --git a/src/pptx_agent/tools/__init__.py b/src/pptx_agent/tools/__init__.py index b6ed859..c7724ca 100644 --- a/src/pptx_agent/tools/__init__.py +++ b/src/pptx_agent/tools/__init__.py @@ -24,9 +24,10 @@ ``set_properties`` and ``batch``; * the shared ``describe``'s deck handler and ``ppt_read_slides`` (:mod:`.read`); * text: ``ppt_set_text`` (speaker notes too) and ``ppt_format_text`` (table cells too); -* shapes: ``ppt_add_shape``, ``ppt_set_shape``, ``ppt_add_picture``, ``ppt_add_connector``, - ``ppt_arrange``; ``ppt_add_shape`` with ``measure: true`` measures text before building; - tables: ``ppt_add_table``, ``ppt_edit_table`` (fill and borders too); +* shapes: ``ppt_add_shape``, ``ppt_set_shape``, ``ppt_add_picture`` (SVG pictures too), + ``ppt_add_connector``, ``ppt_arrange`` (align and distribute too); ``ppt_add_shape`` + with ``measure: true`` measures text before building; tables: ``ppt_add_table``, + ``ppt_edit_table`` (fill and borders too); * slides: ``ppt_add_slide``, ``ppt_draft_slides``, ``ppt_manage_slides``, ``ppt_set_theme``; * charts and SmartArt, through the shared ``edit_chart`` (``action: "read"`` reads one) and ``edit_smartart`` (:mod:`.charts`); @@ -38,6 +39,8 @@ trial 3 -- no model called them in about forty runs. Their library calls stay (:mod:`pptx_agent.edit.layout`, :mod:`~pptx_agent.edit.arrange`, :mod:`~pptx_agent.edit.scales`, :meth:`Slide.copy_shapes`, :mod:`~pptx_agent.edit.svgprofile`). +Align and distribute came back in round 3 as ``ppt_arrange`` actions, for house rules that +require equal spacing: one call that guarantees it, which the application's rules name. Lengths are points at this boundary (12,700 EMU each). Every changing call returns ``checks``: text that does not fit, collisions and shapes off the slide on the slides it @@ -108,7 +111,8 @@ #: take 20 of the 24, so it is left to the dispatcher's validation, as are the option-heavy #: shape tools. edit_chart joined them in T4: with its add action it has 14 optional #: parameters, more than half the request's. ppt_align left the list with the layout tools -#: (post-T4). +#: (post-T4). ppt_arrange, strict until round 3, has eight optional parameters with align +#: and distribute and no longer fits beside the others; ppt_add_table takes its place. STRICT_FIRST = ("ppt_add_slide", "ppt_manage_slides", "ppt_draft_slides") _PRESENTATION = b"ppt/presentation.xml" diff --git a/src/pptx_agent/tools/read.py b/src/pptx_agent/tools/read.py index c117394..8a7f046 100644 --- a/src/pptx_agent/tools/read.py +++ b/src/pptx_agent/tools/read.py @@ -136,6 +136,8 @@ def _shape_json(shape, z: int) -> dict[str, Any]: size = shape.image_size if size is not None: data["image"] = {"px": [size.width, size.height]} + if shape.vector is not None: + data.setdefault("image", {})["svg"] = True elif shape.kind == "graphic_frame": if shape.has_table: table = shape.table diff --git a/src/pptx_agent/tools/shapes.py b/src/pptx_agent/tools/shapes.py index 2485b36..f392015 100644 --- a/src/pptx_agent/tools/shapes.py +++ b/src/pptx_agent/tools/shapes.py @@ -22,6 +22,12 @@ spec_of, text_params, write_text) from .text import COMMON_PRESETS, preset_of +from ..edit.arrange import EDGES + +#: ppt_arrange's align ``to`` and distribute ``within``, as the library's ``to``. +_ALIGN_TO = {"shapes": "selection", "slide": "slide", "content_area": "box"} +_WITHIN = {"shapes": "selection", "content_area": "box"} + GRAPHICS = "ppt_graphics" OBJECTS = "ppt_objects" @@ -283,7 +289,8 @@ def ppt_set_shape(call, doc, items): @tool("ppt_add_picture", "Insert a picture from an image blob on a slide, or replace a picture's image " - "(target), keeping its frame, height or width. Returns its address and native size.", + "(target), keeping its frame, height or width. An SVG stays vector, with a PNG " + "fallback made for it. Returns its address and native size.", {"doc": string("Document id."), "image": string("Image blob handle, e.g. b2."), "slide": string("Slide to insert on, s:256 or $ref.", optional=True), @@ -305,28 +312,41 @@ def ppt_add_picture(call, doc, image, slide=None, target=None, box=None, keep=No if not blob.mime.startswith("image/"): raise ToolError("invalid_arguments", f"{image} is {blob.mime}, not an image", field="image") - if target is not None: - picture = shape_at(deck, target) - if picture.kind != "picture": - raise ToolError("invalid_arguments", f"{target} is a {picture.kind}, not a picture", - field="target") - picture.replace_image(blob.data, keep=keep or "frame", anchor=anchor or "top_left") - address, verb = picture.id, "Replaced the image of" - else: - host = slide_at(deck, slide) - if box is not None: - x, y, w, h = box_emu(box) - picture = host.add_picture(blob.data, x, y, width=w, height=h) + try: + if target is not None: + picture = shape_at(deck, target) + if picture.kind != "picture": + raise ToolError("invalid_arguments", + f"{target} is a {picture.kind}, not a picture", field="target") + picture.replace_image(blob.data, keep=keep or "frame", + anchor=anchor or "top_left") + address, verb = picture.id, "Replaced the image of" else: - left, top, _, _ = host.content_area - picture = host.add_picture(blob.data, left, top) - address, verb = picture.id, "Inserted" + host = slide_at(deck, slide) + if box is not None: + x, y, w, h = box_emu(box) + picture = host.add_picture(blob.data, x, y, width=w, height=h) + else: + left, top, _, _ = host.content_area + picture = host.add_picture(blob.data, left, top) + address, verb = picture.id, "Inserted" + except ImportError as error: # an SVG's PNG fallback needs pptx-agent[png] + raise ToolError("refused", str(error), field="image") from None if ref: call.define_ref(ref, address) call.touch(picture._slide.slide_id, picture.id) - size = picture.image_size - data = {"address": address, "box": box_pt(picture.bounds), - "native_px": [size.width, size.height] if size else None} + vector = picture.vector + if vector is not None: + from ..edit import svgpicture + + native = svgpicture.native_size(svgpicture.parse(vector)) + size = [round(native[0]), round(native[1])] if native else None + else: + image = picture.image_size + size = [image.width, image.height] if image else None + data = {"address": address, "box": box_pt(picture.bounds), "native_px": size} + if vector is not None: + data["svg"] = True return Result(summary=f"{verb} picture {address}", created=[address] if target is None else [], changed=[address] if target is not None else [], data=data) @@ -429,21 +449,31 @@ def ppt_add_connector(call, doc, items, slide=None, key=None): @tool("ppt_arrange", - "Change shapes' z-order, group, ungroup, duplicate or delete them. Returns new " - "addresses.", + "Change shapes' z-order; group, ungroup, duplicate, delete, align or distribute " + "them (equal gaps). Returns new addresses and moved boxes.", {"doc": string("Document id."), "targets": array(string(), "Shapes, or $refs.", min_items=1), "action": string("What to do.", enum=["front", "back", "forward", "backward", "group", - "ungroup", "duplicate", "delete"]), + "ungroup", "duplicate", "delete", "align", + "distribute"]), "dx": number("duplicate: offset. Default 0.", minimum=-10000, maximum=10000, optional=True), "dy": number("duplicate: offset. Default 0.", minimum=-10000, maximum=10000, optional=True), "ref": string("group, or duplicate of one shape: ref name for the new shape.", + optional=True), + "edge": string("align.", enum=list(EDGES), optional=True), + "to": string("align: to the targets' bounds (default), slide or content area.", + enum=list(_ALIGN_TO), optional=True), + "axis": string("distribute.", enum=["horizontal", "vertical"], optional=True), + "within": string("distribute: between the outermost targets (default) or across " + "the content area.", enum=list(_WITHIN), optional=True), + "gap": number("distribute: a fixed gap instead.", minimum=0, maximum=10000, optional=True)}, kind=KIND, group=GRAPHICS, mutates=True, refs=("targets[]",)) @library_errors -def ppt_arrange(call, doc, targets, action, dx=0, dy=0, ref=None): +def ppt_arrange(call, doc, targets, action, dx=0, dy=0, ref=None, edge=None, to=None, + axis=None, within=None, gap=None): deck = call.document shapes = [shape_at(deck, address, field=f"targets[{i}]") for i, address in enumerate(targets)] @@ -451,6 +481,15 @@ def ppt_arrange(call, doc, targets, action, dx=0, dy=0, ref=None): call.touch(shape._slide.slide_id) if action not in ("delete", "ungroup"): call.touch(shape.id) + for name, value, owner in (("edge", edge, "align"), ("to", to, "align"), + ("axis", axis, "distribute"), ("within", within, "distribute"), + ("gap", gap, "distribute")): + if value is not None and action != owner: + raise ToolError("invalid_arguments", f"{name} is for {owner}, not {action}", + field=name) + if action in ("align", "distribute"): + return _line_up(call, shapes, action, edge=edge, to=to, axis=axis, within=within, + gap=gap) created: list[str] = [] removed: list[str] = [] changed: list[str] = [] @@ -492,4 +531,37 @@ def ppt_arrange(call, doc, targets, action, dx=0, dy=0, ref=None): removed=removed, changed=changed) +def _line_up(call, shapes, action, *, edge, to, axis, within, gap) -> Result: + """ppt_arrange's align and distribute: :mod:`pptx_agent.edit.arrange` on the targets, + the content area being :attr:`Slide.content_area ` (the + layout's body placeholders, else the band under the title).""" + from ..edit import arrange + + host = shapes[0]._slide + if any(shape._slide.slide_id != host.slide_id for shape in shapes): + raise ToolError("invalid_arguments", "the targets must be on one slide", + field="targets") + if action == "align": + if edge is None: + raise ToolError("invalid_arguments", "align needs an edge", field="edge", + valid_options=list(EDGES)) + where = to or "shapes" + moved = arrange.align(shapes, edge, to=_ALIGN_TO[where], + box=host.content_area if where == "content_area" else None) + how = f"{edge} of the {where.replace('_', ' ')}" + else: + if axis is None: + raise ToolError("invalid_arguments", "distribute needs an axis", field="axis", + valid_options=["horizontal", "vertical"]) + where = within or "shapes" + moved = arrange.distribute(shapes, axis, to=_WITHIN[where], + box=host.content_area if where == "content_area" else None, + gap=None if gap is None else emu(gap)) + how = f"{axis}ly within the {where.replace('_', ' ')}" + changed = [shape.id for shape in moved] + boxes = {shape.id: box_pt(shape.drawn_bounds) for shape in arrange.outermost(shapes)[:20]} + return Result(summary=f"{action}: {len(changed)} of {len(boxes)} shape(s) moved, {how}", + changed=changed, data={"moved": changed, "boxes": boxes}) + + TOOLS = [ppt_add_shape, ppt_set_shape, ppt_add_picture, ppt_add_connector, ppt_arrange] diff --git a/tests/golden/grading/TASK_p16.md b/tests/golden/grading/TASK_p16.md new file mode 100644 index 0000000..fbf7a6f --- /dev/null +++ b/tests/golden/grading/TASK_p16.md @@ -0,0 +1,11 @@ +# Task: the approach slide, four phases with icons + +`input/halden-proposal-draft.pptx` is the Halden proposal draft: a cover and two content slides that only have titles. Slide 2, "Our approach", should show the programme's four phases in a row, each with its icon above a short label. The icons are the design team's, in `input/diagnose.svg`, `input/design.svg`, `input/negotiate.svg` and `input/embed.svg`. + +- **The phases, left to right:** Diagnose ("Baseline the spend"), Design ("Category strategies"), Negotiate ("Supplier rounds"), Embed ("Savings tracking"). Each label gives the phase name and its line. +- **The icons as SVG pictures**, not bitmaps, so the client can recolour them in PowerPoint; all four the same size, each centred above its label. +- **House rule: equal spacing.** The four steps span the content area from its left edge to its right, with equal gaps between them, and line up on one row. +- **A one-line takeaway title** (a finding, not a label). +- Nothing overlaps or leaves the slide; the other slides do not change. + +Save the result as `work/halden-proposal-draft.pptx`. diff --git a/tests/golden/grading/check_p16.py b/tests/golden/grading/check_p16.py new file mode 100644 index 0000000..53dad99 --- /dev/null +++ b/tests/golden/grading/check_p16.py @@ -0,0 +1,89 @@ +"""P16: the approach slide -- four phases in a row, each an SVG icon centred above its label, +equally spaced across the content area (round 3: SVG pictures and ppt_arrange's align and +distribute).""" +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # common.py beside it +from common import INPUTS, main, norm, title_of # noqa: E402 + +from pptx_agent import Document # noqa: E402 + +FOLDER = Path(os.environ.get("GOLDEN_INPUTS", str(INPUTS))) / "p16-approach-icons" / "input" +PHASES = [("Diagnose", "Baseline the spend", "diagnose.svg"), + ("Design", "Category strategies", "design.svg"), + ("Negotiate", "Supplier rounds", "negotiate.svg"), + ("Embed", "Savings tracking", "embed.svg")] +PT = 12700 + + +def body(c, out: Path): + deck = Document.open(out) + src = Document.open(FOLDER / "halden-proposal-draft.pptx") + c.check("validate() clean", not deck.validate(), [str(p) for p in deck.validate()]) + titles = [title_of(s) for s in deck.slides] + c.check("still 3 slides", len(titles) == 3, titles) + slide = deck.slides[1] + title = next(sh for sh in slide.shapes if sh.placeholder and sh.placeholder[0] == "title") + fit = title.text_fit() + c.check("a one-line title that fits", bool(norm(title.text)) and "\n" not in title.text + and tuple(fit.lines) == (1,) and not fit.overflows, (title.text, fit)) + c.check("a takeaway, not a label", len(norm(title.text).split()) >= 6 + and norm(title.text).lower() not in {"our approach", "approach"}, title.text) + + # slide.shapes lists a group and then its members: the members are what is drawn. + shapes = [s for s in slide.shapes if s.kind != "group" and s.id != title.id] + pictures = [s for s in shapes if s.kind == "picture"] + labels = [s for s in shapes if s.kind != "picture" and norm(s.text)] + c.check("four pictures, four labels", len(pictures) == 4 and len(labels) == 4, + (len(pictures), len(labels))) + if len(pictures) != 4 or len(labels) != 4: + return + pictures.sort(key=lambda s: s.drawn_bounds[0]) + labels.sort(key=lambda s: s.drawn_bounds[0]) + icons = [(FOLDER / name).read_bytes() for _, _, name in PHASES] + c.check("the icons are SVG pictures of the given files, in phase order", + [p.vector for p in pictures] == icons, [p.vector is not None for p in pictures]) + c.check("each has a PNG fallback", all(p.image_part and p.image_part.endswith(".png") + and p.image_size for p in pictures), + [p.image_part for p in pictures]) + c.check("the icons are one size", len({(p.width, p.height) for p in pictures}) == 1, + [(p.width, p.height) for p in pictures]) + texts = [norm(s.text) for s in labels] + c.check("the labels, left to right", all(name in text and line in text for text, (name, line, _) + in zip(texts, PHASES)), texts) + pairs = list(zip(pictures, labels)) + c.check("each icon centred above its label", + all(abs((p.drawn_bounds[0] + p.drawn_bounds[2] / 2) + - (t.drawn_bounds[0] + t.drawn_bounds[2] / 2)) <= 0.5 * PT + and p.drawn_bounds[1] + p.drawn_bounds[3] <= t.drawn_bounds[1] + for p, t in pairs), [(p.drawn_bounds, t.drawn_bounds) for p, t in pairs]) + steps = [] + for p, t in pairs: + a, b = p.drawn_bounds, t.drawn_bounds + left, top = min(a[0], b[0]), min(a[1], b[1]) + steps.append((left, top, max(a[0] + a[2], b[0] + b[2]) - left, + max(a[1] + a[3], b[1] + b[3]) - top)) + gaps = [b[0] - (a[0] + a[2]) for a, b in zip(steps, steps[1:])] + c.check("equal gaps between the steps", gaps and max(gaps) - min(gaps) <= 0.5 * PT + and min(gaps) > 0, [g / PT for g in gaps]) + area = slide.content_area + c.check("the steps span the content area", abs(steps[0][0] - area[0]) <= PT + and abs(steps[-1][0] + steps[-1][2] - (area[0] + area[2])) <= PT, + ([s[0] / PT for s in steps], area)) + c.check("one row: the steps' tops line up", len({s[1] for s in steps}) == 1 + and len({p.drawn_bounds[1] for p in pictures}) == 1, [s[1] / PT for s in steps]) + c.check("below the title, inside the content area", + all(s[1] >= title.top + title.height and s[1] + s[3] <= area[1] + area[3] + PT + for s in steps), (steps, area)) + c.check("nothing overlaps or leaves the slide", not deck.overflows(slides=[2]) + and not slide.collisions(), + (deck.overflows(slides=[2]), slide.collisions())) + same = all(deck.to_outline(slides=[i]).split("\n", 1)[1] + == src.to_outline(slides=[i]).split("\n", 1)[1] for i in (1, 3)) + c.check("other slides unchanged", same) + + +if __name__ == "__main__": + main("p16-approach-icons", body) diff --git a/tests/golden/transcripts/p16.json b/tests/golden/transcripts/p16.json new file mode 100644 index 0000000..16c5593 --- /dev/null +++ b/tests/golden/transcripts/p16.json @@ -0,0 +1,421 @@ +{ + "task": "p16-approach-icons", + "inputs": [ + { + "file": "input/halden-proposal-draft.pptx", + "as": "document", + "sha256": "9b2639c49317af6b6820c2bfd2dfaeb41c37772a116bda318b9a33be116446fe", + "content_sha256": "120457aecbd004f1d3a163b9bf6a5d5203e6ab311b9e52e1acb7c63cd5c2e34c" + }, + { + "file": "input/diagnose.svg", + "as": "blob", + "sha256": "e701720a2923060470061007dc0500ebe6b872eba345b87ca63f86e94c0e6be8" + }, + { + "file": "input/design.svg", + "as": "blob", + "sha256": "0923bfb7dfbea8c060c6c52b28d6a7876ae7ec5d9fee27af33815bd8ef77b1a3" + }, + { + "file": "input/negotiate.svg", + "as": "blob", + "sha256": "8daad0fe015b7c303090f3de446a985ebe7ace3c6047f2dc424b72025f3019e7" + }, + { + "file": "input/embed.svg", + "as": "blob", + "sha256": "6bfbdfb7e02d507fef4692501f2aadedc284716392de8464a19081415f025a10" + } + ], + "calls": [ + { + "tool": "describe", + "arguments": { + "doc": "d1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "batch", + "arguments": { + "ops": [ + { + "tool": "ppt_set_text", + "arguments": { + "doc": "d1", + "items": [ + { + "target": "257.2", + "text": "Four phases take Halden from a spend baseline to tracked savings" + } + ] + } + }, + { + "tool": "ppt_add_picture", + "arguments": { + "doc": "d1", + "image": "b1", + "slide": "s:257", + "box": { + "x": 103, + "y": 180, + "w": 64, + "h": 64 + }, + "ref": "icon1" + } + }, + { + "tool": "ppt_add_picture", + "arguments": { + "doc": "d1", + "image": "b2", + "slide": "s:257", + "box": { + "x": 313, + "y": 180, + "w": 64, + "h": 64 + }, + "ref": "icon2" + } + }, + { + "tool": "ppt_add_picture", + "arguments": { + "doc": "d1", + "image": "b3", + "slide": "s:257", + "box": { + "x": 553, + "y": 180, + "w": 64, + "h": 64 + }, + "ref": "icon3" + } + }, + { + "tool": "ppt_add_picture", + "arguments": { + "doc": "d1", + "image": "b4", + "slide": "s:257", + "box": { + "x": 763, + "y": 180, + "w": 64, + "h": 64 + }, + "ref": "icon4" + } + }, + { + "tool": "ppt_add_shape", + "arguments": { + "doc": "d1", + "slide": "s:257", + "items": [ + { + "preset": "textbox", + "box": { + "x": 40, + "y": 250, + "w": 190, + "h": 50 + }, + "fit_height": true, + "paragraphs": [ + { + "align": "center", + "runs": [ + { + "text": "Diagnose", + "bold": true, + "size": 18, + "color": "tx2" + } + ] + }, + { + "align": "center", + "runs": [ + { + "text": "Baseline the spend", + "size": 14 + } + ] + } + ], + "ref": "label1" + }, + { + "preset": "textbox", + "box": { + "x": 250, + "y": 250, + "w": 190, + "h": 50 + }, + "fit_height": true, + "paragraphs": [ + { + "align": "center", + "runs": [ + { + "text": "Design", + "bold": true, + "size": 18, + "color": "tx2" + } + ] + }, + { + "align": "center", + "runs": [ + { + "text": "Category strategies", + "size": 14 + } + ] + } + ], + "ref": "label2" + }, + { + "preset": "textbox", + "box": { + "x": 490, + "y": 250, + "w": 190, + "h": 50 + }, + "fit_height": true, + "paragraphs": [ + { + "align": "center", + "runs": [ + { + "text": "Negotiate", + "bold": true, + "size": 18, + "color": "tx2" + } + ] + }, + { + "align": "center", + "runs": [ + { + "text": "Supplier rounds", + "size": 14 + } + ] + } + ], + "ref": "label3" + }, + { + "preset": "textbox", + "box": { + "x": 700, + "y": 250, + "w": 190, + "h": 50 + }, + "fit_height": true, + "paragraphs": [ + { + "align": "center", + "runs": [ + { + "text": "Embed", + "bold": true, + "size": 18, + "color": "tx2" + } + ] + }, + { + "align": "center", + "runs": [ + { + "text": "Savings tracking", + "size": 14 + } + ] + } + ], + "ref": "label4" + } + ] + } + } + ] + }, + "expect": { + "ok": true, + "created": [ + "257.3", + "257.4", + "257.5", + "257.6", + "257.7", + "257.8", + "257.9", + "257.10" + ], + "refs": { + "icon1": "257.3", + "icon2": "257.4", + "icon3": "257.5", + "icon4": "257.6", + "label1": "257.7", + "label2": "257.8", + "label3": "257.9", + "label4": "257.10" + } + } + }, + { + "tool": "batch", + "arguments": { + "ops": [ + { + "tool": "ppt_arrange", + "arguments": { + "doc": "d1", + "targets": [ + "$icon1", + "$label1" + ], + "action": "group", + "ref": "step1" + } + }, + { + "tool": "ppt_arrange", + "arguments": { + "doc": "d1", + "targets": [ + "$icon2", + "$label2" + ], + "action": "group", + "ref": "step2" + } + }, + { + "tool": "ppt_arrange", + "arguments": { + "doc": "d1", + "targets": [ + "$icon3", + "$label3" + ], + "action": "group", + "ref": "step3" + } + }, + { + "tool": "ppt_arrange", + "arguments": { + "doc": "d1", + "targets": [ + "$icon4", + "$label4" + ], + "action": "group", + "ref": "step4" + } + } + ] + }, + "expect": { + "ok": true, + "created": [ + "257.11", + "257.12", + "257.13", + "257.14" + ], + "refs": { + "step1": "257.11", + "step2": "257.12", + "step3": "257.13", + "step4": "257.14" + } + } + }, + { + "tool": "ppt_arrange", + "arguments": { + "doc": "d1", + "targets": [ + "$step1", + "$step2", + "$step3", + "$step4" + ], + "action": "distribute", + "axis": "horizontal", + "within": "content_area" + }, + "expect": { + "ok": true + } + }, + { + "tool": "ppt_arrange", + "arguments": { + "doc": "d1", + "targets": [ + "$step1", + "$step2", + "$step3", + "$step4" + ], + "action": "align", + "edge": "middle", + "to": "content_area" + }, + "expect": { + "ok": true + } + }, + { + "tool": "check", + "arguments": { + "doc": "d1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "save_document", + "arguments": { + "doc": "d1", + "name": "halden-proposal-draft.pptx", + "format": "pptx" + }, + "expect": { + "ok": true + } + } + ], + "output": { + "name": "halden-proposal-draft.pptx", + "sha256": "ae2f8570fdd8b8a850086eab1c54fdcee2b1d0fe3f333b5749f0df3aa19733c1", + "calls": 7, + "content_sha256": "a5f69bca4c561c2665a381906a6c11d7f3ba01e5de3a8e99e81ff0ac47345bfc" + }, + "check": { + "total": 17, + "passed": 17 + } +} diff --git a/tests/golden_inputs.py b/tests/golden_inputs.py index 91ab768..5114500 100644 --- a/tests/golden_inputs.py +++ b/tests/golden_inputs.py @@ -1,7 +1,8 @@ """Build the inputs of the trial tasks p2-p6, of the spike's graphics tasks p7, p8, o1 and m1, of p10 (a review in comments), p11 (SmartArt), p12 (a rebrand through the theme), p14 -(a chart and a table on a branded template) and p15 (a maturity gap chart and an -effort-against-impact matrix), for the golden transcripts (test_golden.py). +(a chart and a table on a branded template), p15 (a maturity gap chart and an +effort-against-impact matrix) and p16 (an approach slide: four phases with SVG icons, evenly +spaced), for the golden transcripts (test_golden.py). The trial's own builder (recovered from its coordinator's transcript), run against this repository's fixtures, writes ``//input/...``. p1's template is the committed @@ -592,6 +593,28 @@ def p15() -> None: (folder / "recommendations.csv").write_bytes(P15_RECOMMENDATIONS.encode("utf-8")) +# ---------------------------------------------------------------- P16 approach with icons +#: Four hand-written line icons, one per phase of the Halden approach: SVG as an icon +#: library ships it (a 24-unit grid, a stroke, no fill), the brand blue written in. +_ICON = ('{}\n') +P16_ICONS = { + "diagnose.svg": '', + "design.svg": '', + "negotiate.svg": '', + "embed.svg": '', +} + + +def p16() -> None: + """The Halden draft, and the four phase icons as SVG files.""" + folder = out("p16-approach-icons") + (folder / "halden-proposal-draft.pptx").write_bytes(HALDEN.read_bytes()) + for name, body in P16_ICONS.items(): + (folder / name).write_bytes(_ICON.format(body).encode("utf-8")) + + BUILDERS = {"p2-quarterly-update": p2, "p3-split-slide": p3, "p4-process-diagram": p4, "p5-rebrand": p5, "p6-restructure-deck": p6, "p7-phased-approach": halden("p7-phased-approach"), @@ -600,7 +623,7 @@ def p15() -> None: "m1-priority-matrix": halden("m1-priority-matrix", (3, "Prioritisation")), "p10-review-comments": p10, "p11-update-smartart": p11, "p12-theme-rebrand": p12, "p14-brand-chart-table": p14, - "p15-maturity-charts": p15} + "p15-maturity-charts": p15, "p16-approach-icons": p16} #: Builders that draw from scratch what is otherwise committed (``--draw``). diff --git a/tests/test_golden.py b/tests/test_golden.py index 7b4856f..96e75f4 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -5,7 +5,9 @@ slide with its empty placeholder deleted, and a table in the template's own style without banding) and p15 (a maturity gap chart -- columns, a highlighted point, target markers, a fixed axis, no gridlines, 10 pt text -- and an effort-against-impact bubble matrix labelled -from the workbook), done with the tools alone and replayed. +from the workbook) and p16 (an approach slide: four phases, each an SVG icon above its label, +spaced equally across the content area with ppt_arrange's distribute and align), done with +the tools alone and replayed. Each transcript in ``golden/transcripts`` is a reference solution as tool calls -- what a model would send, with no code and no files -- recorded with what every call returned. @@ -125,8 +127,8 @@ def test_a_golden_transcript_replays_to_a_passing_check(path, inputs, tmp_path): def test_the_goldens_cover_every_task_in_few_calls(): tasks = {json.loads(p.read_text())["task"].split("-")[0]: json.loads(p.read_text()) for p in TRANSCRIPTS} - assert sorted(tasks) == ["m1", "o1", "p1", "p10", "p11", "p12", "p13", "p14", "p15", "p2", "p3", - "p4", "p5", "p6", "p7", "p8", "p9"] + assert sorted(tasks) == ["m1", "o1", "p1", "p10", "p11", "p12", "p13", "p14", "p15", "p16", + "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"] for transcript in tasks.values(): assert len(transcript["calls"]) <= 10 assert all(step["tool"] in {tool.name for tool in TOOLS} for step in transcript["calls"]) diff --git a/tests/test_svg_pictures.py b/tests/test_svg_pictures.py new file mode 100644 index 0000000..fc21168 --- /dev/null +++ b/tests/test_svg_pictures.py @@ -0,0 +1,249 @@ +"""SVG pictures (round 3, I1): a PNG in ``a:blip``, the SVG behind its ``asvg:svgBlip`` +extension, as PowerPoint 2016 and later store an inserted SVG (measured in PowerPoint for +Mac 16: see :mod:`pptx_agent.edit.svgpicture`).""" + +from __future__ import annotations + +import io +import json +import struct +import sys +import zipfile + +import pytest +from lxml import etree + +from images import ORANGE +from pptx_agent import Document +from pptx_agent.edit import svgpicture +from pptx_agent.fullstate.safe import FullStateError, UnsafeInput + +#: A hand-written icon: a disc and a tick, 48 px square. +ICON = (b'' + b'' + b'') +#: The same tick on a green disc: a recoloured icon. +GREEN = ICON.replace(b"#1F5FAD", b"#2E8B57") +WIDE = b'' + +EMU = 914400 // 96 # one pixel at 96 dpi +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" + + +def _slide(): + deck = Document.new() + return deck, deck.add_slide("Blank") + + +def _parts(deck): + archive = zipfile.ZipFile(io.BytesIO(deck.to_bytes())) + return {name: archive.read(name) for name in archive.namelist()} + + +def _png_size(data: bytes) -> tuple[int, int]: + return struct.unpack(">II", data[16:24]) + + +def test_an_svg_picture_is_written_as_powerpoint_writes_one(): + deck, slide = _slide() + picture = slide.add_picture(ICON, 914400, 914400, description="Done") + assert (picture.width, picture.height) == (48 * EMU, 48 * EMU) # its own size, 96 dpi + assert picture.vector == ICON and picture.kind == "picture" + assert picture.name == f"Graphic {int(picture.id.split('.')[1]) - 1}" + assert deck.validate() == [] + parts = _parts(deck) + root = etree.fromstring(parts["ppt/slides/slide1.xml"]) + pic = root.find(f".//{{{P}}}pic") + assert pic.find(f".//{{{A}}}picLocks").attrib == {} # aspect unlocked, as PowerPoint + blip = pic.find(f".//{{{A}}}blip") + ext = blip.find(f"{{{A}}}extLst/{{{A}}}ext") + assert ext.get("uri") == "{96DAC541-7B7A-43D3-8B79-37D633B846F1}" + svg_blip = ext[0] + assert svg_blip.tag == f"{{{svgpicture.ASVG_NS}}}svgBlip" + assert b'' + with pytest.raises(ValueError, match="states no size"): + slide.add_picture(sizeless) + assert slide.add_picture(sizeless, width=914400, height=914400).vector == sizeless + + +def test_identical_svgs_are_stored_once_and_a_given_fallback_is_used_as_is(): + deck, slide = _slide() + first = slide.add_picture(ICON, 0, 0) + second = slide.add_picture(ICON, 914400, 0, width=1828800, fallback=ORANGE) + assert first.vector == second.vector == ICON + parts = _parts(deck) + assert sum(name.endswith(".svg") for name in parts) == 1 + assert ORANGE in parts.values() + with pytest.raises(ValueError, match="not an SVG"): + slide.add_picture(ICON, fallback=ICON) + with pytest.raises(ValueError, match="fallback is for an SVG picture"): + slide.add_picture(ORANGE, fallback=ORANGE) + + +def test_the_fallback_follows_the_dpi_and_a_path_works(tmp_path): + deck, slide = _slide() + path = tmp_path / "icon.svg" + path.write_bytes(ICON) + picture = slide.add_picture(path, 0, 0, width=914400, height=914400, fallback_dpi=96) + assert (picture.image_size.width, picture.image_size.height) == (96, 96) + assert picture.vector == ICON + + +@pytest.mark.parametrize("svg, error, message", [ + (b']>&x;', UnsafeInput, "document type"), + (b'&x;', + FullStateError, "not well-formed"), + (b'', FullStateError, "not an SVG document"), + (b'" in outline + assert outline.count("picture svg") == 1 + assert _hint(f"{picture.id} picture svg").kind == "picture" + + +def test_pptx2svg_draws_the_vector(): + deck, slide = _slide() + slide.add_picture(ICON, 0, 0) + assert "image/svg+xml" in slide.render_svg() + + +# -- the tools ----------------------------------------------------------------------------------- + + +def test_ppt_add_picture_takes_an_svg_blob_to_insert_and_to_replace(): + from ooxml_edit.tools import Toolbox + from pptx_agent.tools import FORMAT, GROUPS, TOOLS + + deck = Document.new() + deck.add_slide("Title Only") + with Toolbox(TOOLS, formats=[FORMAT], groups=GROUPS) as toolbox: + session = toolbox.session() + session.open(deck.to_bytes(), name="icons.pptx") + icon = session.add_blob(ICON, "icon.svg", "image/svg+xml") + made = toolbox.dispatch(session, "ppt_add_picture", { + "doc": "d1", "image": icon, "slide": "s:256", + "box": {"x": 72, "y": 72, "w": 36, "h": 36}, "ref": "icon"}) + assert made.ok, made.to_json() + assert made.data["svg"] is True and made.data["native_px"] == [48, 48] + green = session.add_blob(GREEN, "green.svg", "image/svg+xml") + swapped = toolbox.dispatch(session, "ppt_add_picture", { + "doc": "d1", "image": green, "target": "$icon"}) + assert swapped.ok, swapped.to_json() + shape = session.entry("d1").document.shape(made.data["address"]) + assert shape.vector == GREEN and shape.bounds[2] == 36 * 12700 + geometry = toolbox.dispatch(session, "ppt_read_slides", {"doc": "d1", + "detail": "geometry"}) + entry = next(s for s in geometry.data if s["id"] == shape.id) + assert entry["image"]["svg"] is True + bad = session.add_blob(b'', + "bad.svg", "image/svg+xml") + refused = toolbox.dispatch(session, "ppt_add_picture", {"doc": "d1", "image": bad, + "slide": "s:256"}) + assert not refused.ok and "document type" in json.dumps(refused.to_json()) diff --git a/tests/test_tools.py b/tests/test_tools.py index 37bb2ae..2fd6fac 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -47,11 +47,18 @@ #: colours and markers, series type, chart text, point labels; the radar_filled and bubble #: types; ``scale``, ``style``, ``gridlines``, ``part``, ``labels``, ``cells``, ``secondary``) #: added 646 to every definition (11,113), none to the core; the guard rises to 11,200 rather -#: than squeeze the descriptions. +#: than squeeze the descriptions. Round 3's align and distribute in the deferred ppt_arrange +#: (``edge``, ``to``, ``axis``, ``within``, ``gap``; house rules that require equal spacing) +#: and SVG pictures in ppt_add_picture's description added 196 to every definition (11,309), +#: none to the core (3,398, about 5,029 counted); the guard rises to 11,400. With eight +#: optional parameters ppt_arrange no longer fits Claude's strict limit of 24 optional +#: parameters beside the others, so it is sent non-strict (the dispatcher still validates +#: it) and ppt_add_table takes its place: 29 free-text strings and 24 optional parameters +#: over the strict tools, within the limits of 32 and 24. CHARS_PER_TOKEN = 3.5 COUNTED_PER_ESTIMATED = 1.48 CORE_COUNTED_BUDGET = 5500 -ALL_GUARD = 11200 +ALL_GUARD = 11400 def estimate_tokens(definitions) -> int: @@ -290,6 +297,56 @@ def test_format_text_set_shape_and_arrange(toolbox, session): assert len(ids) == len(set(ids)) and copy.created[0] not in grouped.created +def test_arrange_aligns_and_distributes_in_one_undo_step_each(toolbox, session): + """Round 3, A5: house rules that ask for equal spacing get one call that guarantees it.""" + entry = session.entry("d1") + made = ok(call(toolbox, session, "ppt_add_shape", doc="d1", slide="s:257", items=[ + {"preset": "roundRect", "box": {"x": x, "y": 200 + i * 7, "w": 80, "h": 60}, + "ref": f"t{i}"} for i, x in enumerate([60, 170, 330, 420])])) + tiles = [f"$t{i}" for i in range(4)] + before = entry.version + aligned = ok(call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles, + action="align", edge="top")) + assert entry.version == before + 1 + assert aligned.changed == made.created[1:] and aligned.data["moved"] == aligned.changed + assert {box["y"] for box in aligned.data["boxes"].values()} == {200.0} + spread = ok(call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles, + action="distribute", axis="horizontal")) + xs = sorted(box["x"] for box in spread.data["boxes"].values()) + assert xs == [60.0, 180.0, 300.0, 420.0] # the outer two stay put + deck = entry.document + area = deck.slides[1].content_area + across = ok(call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles, + action="distribute", axis="horizontal", within="content_area")) + boxes = sorted(across.data["boxes"].values(), key=lambda box: box["x"]) + assert boxes[0]["x"] == round(area[0] / 12700, 2) + assert boxes[-1]["x"] + 80 == pytest.approx((area[0] + area[2]) / 12700, abs=0.01) + gaps = {round(b["x"] - a["x"] - a["w"], 1) for a, b in zip(boxes, boxes[1:])} + assert len(gaps) == 1 + fixed = ok(call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles, + action="distribute", axis="horizontal", gap=12)) + boxes = sorted(fixed.data["boxes"].values(), key=lambda box: box["x"]) + assert [b["x"] - a["x"] - a["w"] for a, b in zip(boxes, boxes[1:])] == [12.0] * 3 + middle = ok(call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles, + action="align", edge="center", to="slide")) + assert {box["x"] for box in middle.data["boxes"].values()} == {440.0} + before = entry.version + ok(call(toolbox, session, "undo", doc="d1")) + assert len({box["x"] for box in fixed.data["boxes"].values()}) == 4 + assert sorted(round(deck_shape.left / 12700, 2) for deck_shape in + (entry.document.shape(a) for a in made.created)) == \ + [box["x"] for box in boxes] + for arguments, field in (({"action": "align"}, "edge"), + ({"action": "distribute"}, "axis"), + ({"action": "align", "edge": "left", "gap": 4}, "gap"), + ({"action": "group", "edge": "left"}, "edge")): + refused = call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles, **arguments) + assert not refused.ok and refused.error.field == field + two = call(toolbox, session, "ppt_arrange", doc="d1", targets=tiles[:2], + action="distribute", axis="vertical") + assert not two.ok and "three shapes" in two.error.message + + def test_connector_sides_face_each_other(toolbox, session): made = ok(call(toolbox, session, "ppt_add_shape", doc="d1", slide="s:256", items=[ {"preset": "rect", "box": {"x": 300, "y": 150, "w": 100, "h": 40}, "ref": "top"}, diff --git a/tests/test_usage.py b/tests/test_usage.py index 2cb599a..2d819ab 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -38,12 +38,12 @@ def _check(*values, expected: str) -> None: def test_the_guide_has_the_common_tasks(): titles = [title for title, _ in _examples()] - assert len(titles) == 27, titles + assert len(titles) == 28, titles for wanted in ("Units", "Text frames", "Collisions", "Measure text", "change its colours", "Open a deck", "Find text", "mixed formatting", "Tables", "Charts", "SmartArt", "Fills and outlines", "connectors and groups", "Slides", "insert_outline", "Speaker notes", "text fit", "Layouts", "Theme", - "Pictures", "Full-state SVG", "New decks and templates", + "Pictures", "SVG pictures", "Full-state SVG", "New decks and templates", "Render, validate, save", "Undo", "One text spec", "Agent tools", "Review comments"): assert any(wanted in title for title in titles), wanted