pptx/image-crop-rotation--crop-all-sides

A <p:pic> on a slide can carry two independent geometric adjustments: cropping (percentage of each edge trimmed off, encoded in <a:blipFill>/<a:srcRect>) and rotation (angle applied to the picture's bounding box, encoded in <p:spPr>/<a:xfrm>/@rot). The manifest expands to four cases: crop-top-20pct (srcRect@t=20000), crop-all-sides (srcRect with all four l/t/r/b attrs set), rotate-90 (xfrm/@rot=5400000 = 90 degrees), rotate-180 (xfrm/@rot=10800000 = 180 degrees). Crop values are expressed in 1000ths of a percent, so 20000 = 20%. Rotation values are expressed in 60,000ths of a degree.

Library verdicts: python-pptx: — pptxjs: —

Metadata

Feature idpptx/image-crop-rotation--crop-all-sides
Formatpptx
Categoryimages
Familypptx/image-crop-rotation
Axis valuesvariant=crop-all-sides
Spececma-376-5-part-1 § 20.1.8.55 a:srcRect

XPath assertions

ID / partPredicateXPathpython-pptxpptxjs
pic-present-crop-all-sides
ppt/slides/slide1.xml
exist
The slide must carry a <p:pic> element.
//p:pic
variant-attribute-crop-all-sides
ppt/slides/slide1.xml
equal = 5000,10000,5000,10000
The picture must carry the expected crop-all-sides marker — either <a:srcRect> with the specified edge attribute(s), or <a:xfrm>/@rot with the specified angle (in 60,000ths of a degree).
concat(//p:pic/p:blipFill/a:srcRect/@l, ',', //p:pic/p:blipFill/a:srcRect/@t, ',', //p:pic/p:blipFill/a:srcRect/@r, ',', //p:pic/p:blipFill/a:srcRect/@b)

Render assertions

No render assertions declared.

Generator source

scripts/gen_image_crop_rotation.py

#!/usr/bin/env python3
"""Generate a single ``fixtures/pptx/image-crop-rotation--<id>.pptx``.

Parameterised generator: the ``features/pptx/image-crop-rotation.json``
manifest expands to four variants — crop-top-20pct, crop-all-sides,
rotate-90, rotate-180 — 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:pic> with a 1x1 RGBA PNG embedded via shapes.add_picture. The
generator then patches the picture's XML to carry the variant-
specific marker:

- crop-top-20pct: adds <a:blipFill>/<a:srcRect t="20000"/>. 20000 =
  20% in 1000ths of a percent.
- crop-all-sides: adds <a:srcRect l=... t=... r=... b=.../> with all
  four edges cropped (5%, 10%, 5%, 10%).
- rotate-90: sets <p:spPr>/<a:xfrm>/@rot to 5,400,000 (= 90 * 60000).
- rotate-180: sets @rot to 10,800,000 (= 180 * 60000).

python-pptx has no high-level srcRect API; Picture.rotation exists
but the generator stamps @rot via lxml to keep rotate-* and crop-*
paths uniform.
"""

from __future__ import annotations

import argparse
import datetime as _dt
import io
from pathlib import Path

from lxml import etree
from pptx import Presentation
from pptx.oxml.ns import qn
from pptx.util import Inches

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

# Same 1x1 RGBA PNG as gen_image_on_slide.py — embedded inline so the
# generator has no filesystem dependency beyond the python-pptx package.
_PNG_BYTES = bytes.fromhex(
    "89504E470D0A1A0A"
    "0000000D49484452000000010000000108060000001F15C489"
    "0000000D49444154789C63000100000500010D0A2DB4"
    "0000000049454E44AE426082"
)


def _set_srcRect(pic, **edges: str) -> None:
    """Insert <a:srcRect> inside <p:blipFill> with the given l/t/r/b attrs.

    The schema places <a:srcRect> as the first child of <a:blipFill>
    (before <a:stretch>). All four attributes default to 0 when absent;
    the generator only sets the ones the variant exercises.
    """
    blipFill = pic.find(qn("p:blipFill"))
    if blipFill is None:
        raise RuntimeError("picture has no <p:blipFill>")
    srcRect = etree.SubElement(blipFill, qn("a:srcRect"))
    # Move srcRect to be the first child after <a:blip> to satisfy the
    # schema ordering <a:blip><a:srcRect><a:stretch>.
    blip = blipFill.find(qn("a:blip"))
    stretch = blipFill.find(qn("a:stretch"))
    blipFill.remove(srcRect)
    insert_idx = list(blipFill).index(stretch) if stretch is not None else (
        list(blipFill).index(blip) + 1 if blip is not None else 0
    )
    blipFill.insert(insert_idx, srcRect)
    for attr_name, attr_value in edges.items():
        srcRect.set(attr_name, attr_value)


def _set_rotation(pic, rot_value: int) -> None:
    """Stamp <p:spPr>/<a:xfrm>/@rot with the given angle (60,000ths of deg)."""
    spPr = pic.find(qn("p:spPr"))
    if spPr is None:
        raise RuntimeError("picture has no <p:spPr>")
    xfrm = spPr.find(qn("a:xfrm"))
    if xfrm is None:
        raise RuntimeError("picture has no <a:xfrm>")
    xfrm.set("rot", str(rot_value))


def _write(variant: str, out: Path) -> None:
    prs = Presentation()
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    pic = slide.shapes.add_picture(
        io.BytesIO(_PNG_BYTES), Inches(2), Inches(2), Inches(3), Inches(3)
    )
    pic_el = pic._element  # pyright: ignore[reportPrivateUsage]

    if variant == "crop-top-20pct":
        _set_srcRect(pic_el, t="20000")
    elif variant == "crop-all-sides":
        _set_srcRect(pic_el, l="5000", t="10000", r="5000", b="10000")
    elif variant == "rotate-90":
        _set_rotation(pic_el, 5_400_000)
    elif variant == "rotate-180":
        _set_rotation(pic_el, 10_800_000)
    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=["crop-top-20pct", "crop-all-sides", "rotate-90", "rotate-180"],
        help="Crop/rotation 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 (expanded)

{
  "$schema": "../manifest.schema.json",
  "id": "pptx/image-crop-rotation--crop-all-sides",
  "kind": "literal",
  "title": "Image crop and rotation",
  "format": "pptx",
  "category": "images",
  "summary": "A <p:pic> on a slide can carry two independent geometric adjustments: cropping (percentage of each edge trimmed off, encoded in <a:blipFill>/<a:srcRect>) and rotation (angle applied to the picture's bounding box, encoded in <p:spPr>/<a:xfrm>/@rot). The manifest expands to four cases: crop-top-20pct (srcRect@t=20000), crop-all-sides (srcRect with all four l/t/r/b attrs set), rotate-90 (xfrm/@rot=5400000 = 90 degrees), rotate-180 (xfrm/@rot=10800000 = 180 degrees). Crop values are expressed in 1000ths of a percent, so 20000 = 20%. Rotation values are expressed in 60,000ths of a degree.",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "20.1.8.55",
    "element": "a:srcRect",
    "rnc_reference": "spec/ecma-376-5/part-1/rnc/DrawingML.rnc",
    "xsd_reference": "spec/ecma-376-5/part-1/xsd/dml-shape.xsd",
    "notes": "<a:srcRect> is a child of <a:blipFill> that crops the source image before it is stretched to fill the shape. The l/t/r/b attributes (ST_Percentage, 1000ths of a percent) give the distance from each edge of the source to the corresponding edge of the crop rectangle; absent attributes default to 0 (no crop on that edge). <a:xfrm>/@rot (ST_PositiveFixedAngle, 60,000ths of a degree) rotates the shape's bounding box clockwise. The two adjustments compose: a rotated-and-cropped picture carries both an <a:srcRect> inside <a:blipFill> and an @rot attribute on the shape-level <a:xfrm>. python-pptx exposes Picture.rotation but has no high-level srcRect API, so the generator patches <a:srcRect> directly."
  },
  "fixtures": {
    "machine": "pptx/image-crop-rotation--crop-all-sides"
  },
  "generator": {
    "python": "scripts/gen_image_crop_rotation.py",
    "arg_template": "--variant {variant.id} --out fixtures/pptx/image-crop-rotation--{variant.id}.pptx"
  },
  "_expansion": {
    "parent_id": "pptx/image-crop-rotation",
    "bindings": {
      "variant": "crop-all-sides"
    }
  },
  "assertions": [
    {
      "id": "pic-present-crop-all-sides",
      "part": "ppt/slides/slide1.xml",
      "namespaces": {
        "p": "http://schemas.openxmlformats.org/presentationml/2006/main"
      },
      "xpath": "//p:pic",
      "must": "exist",
      "description": "The slide must carry a <p:pic> element."
    },
    {
      "id": "variant-attribute-crop-all-sides",
      "part": "ppt/slides/slide1.xml",
      "namespaces": {
        "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
        "a": "http://schemas.openxmlformats.org/drawingml/2006/main"
      },
      "xpath": "concat(//p:pic/p:blipFill/a:srcRect/@l, ',', //p:pic/p:blipFill/a:srcRect/@t, ',', //p:pic/p:blipFill/a:srcRect/@r, ',', //p:pic/p:blipFill/a:srcRect/@b)",
      "must": "equal",
      "value": "5000,10000,5000,10000",
      "description": "The picture must carry the expected crop-all-sides marker — either <a:srcRect> with the specified edge attribute(s), or <a:xfrm>/@rot with the specified angle (in 60,000ths of a degree)."
    }
  ]
}

Fixture

Download image-crop-rotation--crop-all-sides.pptx (27.8 KB)

Reference preview

Reference (machine, page 1 PNG)

pptx/image-crop-rotation--crop-all-sides page 1 reference

Reference PDF


Download PDF Open in PDF.js

Spec notes

<a:srcRect> is a child of <a:blipFill> that crops the source image before it is stretched to fill the shape. The l/t/r/b attributes (ST_Percentage, 1000ths of a percent) give the distance from each edge of the source to the corresponding edge of the crop rectangle; absent attributes default to 0 (no crop on that edge). <a:xfrm>/@rot (ST_PositiveFixedAngle, 60,000ths of a degree) rotates the shape's bounding box clockwise. The two adjustments compose: a rotated-and-cropped picture carries both an <a:srcRect> inside <a:blipFill> and an @rot attribute on the shape-level <a:xfrm>. python-pptx exposes Picture.rotation but has no high-level srcRect API, so the generator patches <a:srcRect> directly.