API: Compose, Lint & Render
power_pptx.compose fork
from power_pptx import Presentation
from power_pptx.compose import from_spec
spec = {
"slides": [
{"layout": "title", "title": "Q4 Review"},
# free-standing shapes, applied after the layout runs
{"layout": "blank", "shapes": [
{"name": "card", "shape": "rounded_rectangle",
"left": 1, "top": 1.4, "width": 4, "height": 2, # inches
"layer": "card"},
{"name": "badge", "shape": "oval", "text": "NEW",
"left": 4.4, "top": 1.0, "width": 1.2, "height": 0.8,
"layer_above": "card", # asserts z-order
"allow_overlap_with": "card"}, # by spec name; may point forward
]},
],
"lint": "raise", # raise | warn | off
}
prs = from_spec(spec)
source = Presentation("template.pptx")
prs.import_slide(source.slides[0]) # deep-copy between decks
prs.apply_template("corporate.potx") # masters, layouts, themepower_pptx.lint fork
slide = prs.slides[0]
report = slide.lint() # SlideLintReport
report.issues # list[LintIssue]
report.auto_fix() # mutates: OffSlide, TextOverflow,
# OffGridDrift, LayerOrderViolation
slide.tidy() # one-call lint + safe fixes
slide.tidy(fix_grid_drift=True, # per-fix opt in / out
fix_layer_order=False)
from power_pptx.lint import (
LintSeverity, LintIssue,
TextOverflow, OffSlide, OffSlideShadow,
ShapeCollision, ShapeCollisionShadow,
MinFontSize, OffGridDrift, LowContrast,
ZOrderAnomaly, LayerOrderViolation,
MasterPlaceholderCollision,
)
errors = [i for i in report.issues if i.severity is LintSeverity.ERROR]
# Declaring an intentional overlap, widest to narrowest
slide.lint_group("kpi-1", card, accent_bar, value_box) # n-ary, symmetric
badge.allow_overlap_with(card) # exactly one pair
badge.disallow_overlap_with(card) # revoke
badge.overlap_allowances # frozenset[int] of shape ids
card.layer = "card" # directional - asserts z-order
badge.layer_above = "card" # contradicted => LayerOrderViolation
# Save-time gate on any deck, spec-built or not
prs.lint_on_save = "raise" # off (default) | warn | raise
prs.save("out.pptx") # LintError, and nothing writtenThe linter in CI fork
summary() is for a human reading a terminal. Everything else onSlideLintReport exists so a machine can consume the result: serialize it, hand it to a code-scanning service, or compare it against what the deck looked like before your change. The three steps below compose into one CI job.
1. Serialize the report
report = prs.slides[0].lint()
report.summary() # human-readable, for a terminal or a chat reply
report.to_dict() # {"has_errors": bool, "issue_count": int, "issues": [...]}
report.to_json() # the same, serialized (indent=2; pass indent=None for one line)
# Every issue dict is self-describing - the four base fields plus whatever
# the subclass adds, so a consumer needs no per-code special-casing.
report.to_dict()["issues"][0]
# {'code': 'OffSlide', 'severity': 'error',
# 'message': "Shape 'Off card' extends beyond the right edge of the slide.",
# 'shapes': ['Off card'], 'side': 'right'}to_dict() walks each issue's dataclass fields, so subclass-specific detail —ratio on TextOverflow, side on OffSlide, the collision scoring on ShapeCollision — comes along automatically. Shapes are reduced to their names, since shape objects aren't serializable. That makes the payload safe to feed straight to a dashboard or an LLM auto-fix loop; parsing summary() is always the wrong move.
2. Export SARIF for code scanning
SARIF (Static Analysis Results Interchange Format) v2.1.0 is what GitHub code scanning ingests. Upload the document from a workflow and every lint issue shows up as an annotation on the pull request, in the same place compiler and security findings appear.
import json
from power_pptx import Presentation
from power_pptx.lint import lint_report_to_sarif
prs = Presentation("deck.pptx")
reports = [slide.lint() for slide in prs.slides]
# Whole deck in one document, each result stamped with its slide index.
sarif = lint_report_to_sarif(reports) # plain json.dumps-able dict
with open("lint.sarif", "w") as f:
json.dump(sarif, f, indent=2)
# Or per slide, when you are checking as you build.
reports[0].to_sarif(slide_index=0)
reports[0].to_sarif_json(slide_index=0) # the same, as a JSON string
sarif["version"] # "2.1.0"
sarif["runs"][0]["tool"]["driver"]["name"] # "power-pptx-lint"
[r["id"] for r in sarif["runs"][0]["tool"]["driver"]["rules"]] # ['OffSlide']
sarif["runs"][0]["results"][0]
# {'ruleId': 'OffSlide', 'level': 'error',
# 'message': {'text': "Shape 'Off card' extends beyond the right edge ..."},
# 'locations': [{'physicalLocation': {'artifactLocation': {'uri': 'slide/0'}},
# 'logicalLocations': [{'name': 'Off card', 'kind': 'shape'},
# {'fullyQualifiedName': 'slide[0]',
# 'kind': 'slide'}]}],
# 'partialFingerprints': {'powerPptxLintFingerprint/v1': '653d57f3827e'},
# 'properties': {'shapes': ['Off card'], 'slideIndex': 0}}The document carries a single run whose tool.driver is namedpower-pptx-lint, with a rules entry synthesized for each distinct issue code actually present (so the rule list stays honest rather than listing every rule the linter knows). Severities map ERROR → error,WARNING → warning, INFO → note — SARIF's lowest level is called "note".
A deck has no per-slide file on disk, so each result is located at a syntheticslide/<n> artifact URI plus logical locations naming the involved shapes. Those URIs are stable across runs, which is what keeps GitHub's annotations landing on the same logical file instead of churning. Each result also carries the issue's stable digest underpartialFingerprints, so the code-scanning UI can track one alert through re-runs. lint_report_to_sarif takes one report or a sequence of them and acceptsstart_index= when the reports don't begin at slide 0.
3. Fail only on new issues
Most real decks have a residue of accepted warnings. Gating onhas_errors means either fixing all of them today or turning the check off, so the report also exposes a stable identity per issue and a diff built on it.
current = Presentation("deck.pptx").slides[0].lint()
baseline = Presentation("baseline.pptx").slides[0].lint()
current.fingerprints() # ['653d57f3827e', '063ccfc2bc27'] - one per
# issue, positionally aligned with .issues
current.diff(baseline) # issues in current the baseline didn't have
current.diff_detail(baseline) # {"added": [...], "fixed": [...]}
for issue in current.diff(baseline):
print("new:", issue)
# new: [ERROR] OffSlide: Shape 'Stray badge' extends beyond the bottom edge ...A fingerprint is a 12-character digest of the issue's content: the rule code, the names of the shapes involved, and the classifying field (side forOffSlide, kind for ShapeCollision, axis forOffGridDrift, layer for LayerOrderViolation). Volatile detail — exact overlap area, absolute position — is deliberately excluded. The practical consequence: nudging a shape that is still off-slide keeps its fingerprint and isnot reported as new, while renaming a shape mints a new one. Fingerprints are also ordered to match report.issues, so you can zip() the two.
diff() compares two live SlideLintReport objects, which is what you want when both decks are in hand — before/after an auto-fix pass, or generated deck versus the committed reference. diff_detail() adds the other half, the issues the baseline had that are now gone, which is what lets a job report progress instead of only regressions. When the baseline instead lives in the repository across CI runs, storefingerprints() and do the set arithmetic yourself:
"""ci_lint.py - fail only on issues the baseline didn't already have."""
import json
import pathlib
import sys
from power_pptx import Presentation
from power_pptx.lint import lint_report_to_sarif
BASELINE = pathlib.Path("lint-baseline.json")
prs = Presentation("deck.pptx")
reports = [slide.lint() for slide in prs.slides]
# Refresh the checked-in baseline: python ci_lint.py --update-baseline
if "--update-baseline" in sys.argv:
BASELINE.write_text(json.dumps(
{str(i): r.fingerprints() for i, r in enumerate(reports)}, indent=2))
sys.exit(0)
known = json.loads(BASELINE.read_text()) if BASELINE.exists() else {}
new = [
(i, issue)
for i, report in enumerate(reports)
for issue, fp in zip(report.issues, report.fingerprints())
if fp not in set(known.get(str(i), ()))
]
# Upload this and every issue lands as an annotation on the PR.
pathlib.Path("lint.sarif").write_text(
json.dumps(lint_report_to_sarif(reports), indent=2))
for i, issue in new:
print(f"slide {i}: {issue}")
sys.exit(1 if new else 0)Note the two gates are complementary rather than alternatives:prs.lint_on_save (see Lint & Audit) stops a broken deck from ever being written, while this job answers the softer question of whether a change made things worse. audit(prs) is the human-facing sibling — it aggregates lint across every slide alongside broken pictures, empty slides and font warnings, and renders to Markdown for a CI log or a chat reply.
power_pptx.render fork
prs.render_thumbnails(out_dir="thumbs")
from power_pptx.render import render_slides
render_slides(prs, slides=[0, 1], out_dir="thumbs",
name_template="slide-{:02d}.png")
# raises ThumbnailRendererUnavailable without soffice on PATHpower_pptx.animation & transitions fork
from power_pptx import BBox
from power_pptx.animation import Entrance, Exit, Emphasis, MotionPath, Trigger
from power_pptx.enum.presentation import MSO_TRANSITION_TYPE
from power_pptx.util import Inches
shape = slide.shapes.add_text(BBox.from_inches(1, 2, 4, 1), text="Animate me")
Entrance.fade(slide, shape)
Entrance.fly_in(slide, shape, direction="left")
Emphasis.pulse(slide, shape, trigger=Trigger.WITH_PREVIOUS)
Exit.wipe(slide, shape, trigger=Trigger.AFTER_PREVIOUS)
Entrance.fade(slide, shape.text_frame, by_paragraph=True)
MotionPath.arc(slide, shape, dx=Inches(2), dy=Inches(0), height=0.4)
with slide.animations.sequence():
Entrance.fade(slide, shape) # first: on click
Emphasis.pulse(slide, shape) # then: after previous
slide.transition.kind = MSO_TRANSITION_TYPE.MORPH
slide.transition.duration = 600 # ms
prs.set_transition(kind=MSO_TRANSITION_TYPE.FADE, duration=300)power_pptx.theme & inherit fork
from power_pptx.dml.color import RGBColor
from power_pptx.enum.dml import MSO_THEME_COLOR
deck = Presentation("deck.pptx")
theme = deck.theme
theme.colors[MSO_THEME_COLOR.ACCENT_1] # read -> RGBColor
theme.colors[MSO_THEME_COLOR.ACCENT_1] = RGBColor.from_hex("#0B5CFF")
theme.fonts.major # heading font
theme.fonts.major = "Inter Display"
theme.fonts.minor = "Inter"
theme.to_dark_mode() # one-call dark palette
from power_pptx.inherit import resolve_color
run = shape.text_frame.paragraphs[0].runs[0]
rgb = resolve_color(run.font.color, theme=theme) # scheme -> effective RGB