pptx/grouped-shapes

Group two or more shapes under a single <p:grpSp> element, optionally with a <p:grpSpPr>/<a:xfrm> that rotates the whole group. The manifest expands into three cases: two-shape-group (two rectangles), three-shape-group (rectangle + oval + line connector), and rotated-group (two shapes under a group with xfrm/@rot set to 45 degrees = 2,700,000 sixty-thousandths).

Cases (3)

Feature IDAxis bindingspython-pptxpptxjs
pptx/grouped-shapes--rotated-groupvariant=rotated-group
pptx/grouped-shapes--three-shape-groupvariant=three-shape-group
pptx/grouped-shapes--two-shape-groupvariant=two-shape-group

Aggregate

LibraryPassFailPending
python-pptx003
pptxjs003

Parameter axes

Spec notes

<p:grpSp> is a shape-container that collects <p:sp>, <p:pic>, <p:cxnSp>, and nested <p:grpSp> children under a single group. Its <p:grpSpPr>/<a:xfrm> carries the group's composite transform: off/ext describe where the group sits on the slide, and chOff/chExt describe the child-coordinate-system offset and extent (i.e. where the children believe they live). The optional @rot on <a:xfrm> rotates the entire group about its centre. python-pptx exposes add_group_shape(shapes=(...)) which wraps existing shapes into a new grpSp and recalculates extents.

Generator source

scripts/gen_grouped_shapes.py — runs with --arg_template --variant {variant.id} --out fixtures/pptx/grouped-shapes--{variant.id}.pptx

#!/usr/bin/env python3
"""Generate a single ``fixtures/pptx/grouped-shapes--<id>.pptx``.

Parameterised generator: the ``features/pptx/grouped-shapes.json``
manifest expands to three variants — two-shape-group,
three-shape-group, rotated-group — and the conformance runner invokes
this script once per variant with ``--variant <id> --out <path>``.

Each variant produces a one-slide presentation containing a single
<p:grpSp> collecting two or three child shapes:

- two-shape-group: two rectangles grouped together
- three-shape-group: a rectangle, an oval, and a straight-line
  connector grouped together
- rotated-group: two rectangles grouped, with the group's
  <p:grpSpPr>/<a:xfrm>/@rot set to 2,700,000 (= 45 degrees in
  60,000ths of a degree)

python-pptx's high-level ``shapes.add_group_shape(shapes=(...))``
wraps a set of already-added shapes into a new <p:grpSp> and
recalculates the composite extents. For the rotated variant the
generator then patches @rot directly on the group's <a:xfrm>.
"""

from __future__ import annotations

import argparse
import datetime as _dt
from pathlib import Path

from pptx import Presentation
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.util import Inches

_REPO_ROOT = Path(__file__).resolve().parent.parent
_REPRODUCIBLE_DT = _dt.datetime(2000, 1, 1, 0, 0, 0)

# @rot is expressed in 60,000ths of a degree. 45 * 60000 = 2,700,000.
_ROT_45_DEG = 2_700_000


def _write(variant: str, out: Path) -> None:
    prs = Presentation()
    # "Title Only" layout — unused title placeholder plus our shapes.
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    shapes = slide.shapes

    if variant == "two-shape-group":
        rect1 = shapes.add_shape(
            MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
        )
        rect2 = shapes.add_shape(
            MSO_SHAPE.RECTANGLE, Inches(4), Inches(1), Inches(2), Inches(1)
        )
        shapes.add_group_shape([rect1, rect2])

    elif variant == "three-shape-group":
        rect = shapes.add_shape(
            MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
        )
        oval = shapes.add_shape(
            MSO_SHAPE.OVAL, Inches(4), Inches(1), Inches(2), Inches(1)
        )
        line = shapes.add_connector(
            MSO_CONNECTOR.STRAIGHT, Inches(1), Inches(3), Inches(6), Inches(3)
        )
        shapes.add_group_shape([rect, oval, line])

    elif variant == "rotated-group":
        rect1 = shapes.add_shape(
            MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
        )
        rect2 = shapes.add_shape(
            MSO_SHAPE.RECTANGLE, Inches(4), Inches(2), Inches(2), Inches(1)
        )
        group = shapes.add_group_shape([rect1, rect2])
        # add_group_shape creates <p:grpSpPr>/<a:xfrm> as part of
        # recalculate_extents. Patch @rot on that xfrm directly.
        grpSpPr = group._element.grpSpPr  # pyright: ignore[reportPrivateUsage]
        xfrm = grpSpPr.get_or_add_xfrm()
        xfrm.set("rot", str(_ROT_45_DEG))

    else:
        raise ValueError(f"unknown variant: {variant!r}")

    out.parent.mkdir(parents=True, exist_ok=True)
    prs.save(out, zip_date_time=_REPRODUCIBLE_DT)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--variant",
        required=True,
        choices=["two-shape-group", "three-shape-group", "rotated-group"],
        help="Group variant id.",
    )
    parser.add_argument(
        "--out",
        required=True,
        help="Output fixture path (absolute or repo-relative).",
    )
    args = parser.parse_args()

    out_path = Path(args.out)
    if not out_path.is_absolute():
        out_path = _REPO_ROOT / out_path
    _write(args.variant, out_path)
    print(out_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Manifest (parent, unexpanded)

{
  "$schema": "../manifest.schema.json",
  "id": "pptx/grouped-shapes",
  "kind": "parameterised",
  "title": "Grouped shapes",
  "format": "pptx",
  "category": "shapes",
  "summary": "Group two or more shapes under a single <p:grpSp> element, optionally with a <p:grpSpPr>/<a:xfrm> that rotates the whole group. The manifest expands into three cases: two-shape-group (two rectangles), three-shape-group (rectangle + oval + line connector), and rotated-group (two shapes under a group with xfrm/@rot set to 45 degrees = 2,700,000 sixty-thousandths).",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "19.3.1.17",
    "element": "p:grpSp",
    "rnc_reference": "spec/ecma-376-5/part-1/rnc/pml.rnc",
    "xsd_reference": "spec/ecma-376-5/part-1/xsd/pml.xsd",
    "notes": "<p:grpSp> is a shape-container that collects <p:sp>, <p:pic>, <p:cxnSp>, and nested <p:grpSp> children under a single group. Its <p:grpSpPr>/<a:xfrm> carries the group's composite transform: off/ext describe where the group sits on the slide, and chOff/chExt describe the child-coordinate-system offset and extent (i.e. where the children believe they live). The optional @rot on <a:xfrm> rotates the entire group about its centre. python-pptx exposes add_group_shape(shapes=(...)) which wraps existing shapes into a new grpSp and recalculates extents."
  },
  "fixtures": {
    "machine": "pptx/grouped-shapes"
  },
  "generator": {
    "python": "scripts/gen_grouped_shapes.py",
    "arg_template": "--variant {variant.id} --out fixtures/pptx/grouped-shapes--{variant.id}.pptx"
  },
  "parameters": {
    "variant": [
      {
        "id": "two-shape-group",
        "child_count": "2",
        "rot_pattern": "^$"
      },
      {
        "id": "three-shape-group",
        "child_count": "3",
        "rot_pattern": "^$"
      },
      {
        "id": "rotated-group",
        "child_count": "2",
        "rot_pattern": "^2700000$"
      }
    ]
  },
  "assertions_template": [
    {
      "id": "grpSp-present-{variant.id}",
      "part": "ppt/slides/slide1.xml",
      "namespaces": {
        "p": "http://schemas.openxmlformats.org/presentationml/2006/main"
      },
      "xpath": "//p:grpSp",
      "must": "exist",
      "description": "The slide must carry at least one <p:grpSp> group-shape element."
    },
    {
      "id": "grpSp-child-count-{variant.id}",
      "part": "ppt/slides/slide1.xml",
      "namespaces": {
        "p": "http://schemas.openxmlformats.org/presentationml/2006/main"
      },
      "xpath": "string(count(//p:grpSp/*[self::p:sp or self::p:pic or self::p:cxnSp or self::p:grpSp]))",
      "must": "equal",
      "value": "{variant.child_count}",
      "description": "The group must hold exactly {variant.child_count} shape-kind children (p:sp / p:pic / p:cxnSp / nested p:grpSp)."
    },
    {
      "id": "grpSp-rotation-{variant.id}",
      "part": "ppt/slides/slide1.xml",
      "namespaces": {
        "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
        "a": "http://schemas.openxmlformats.org/drawingml/2006/main"
      },
      "xpath": "string(//p:grpSp/p:grpSpPr/a:xfrm/@rot)",
      "must": "match",
      "value": "{variant.rot_pattern}",
      "description": "The group's <a:xfrm>/@rot attribute must match {variant.rot_pattern} — empty string (no rotation) for the two- and three-shape groups, 2700000 (= 45 degrees in 60,000ths) for the rotated group."
    }
  ]
}