PPTX Quality Audit
offipy audit is a set of static geometry quality gates: without opening PowerPoint and
without depending on Microsoft Office, it directly parses .pptx (ZIP+XML) structured shape
extraction, checks out-of-bounds / edge-adjacent / overlapping / text overflow / autofit risks,
produces text / json / markdown / html reports, and blocks non-conforming artifacts by severity
threshold.
- Emits a stable
rule_id(not a natural-language message) — users / CI depend on it for automation. - Configurable suppressions, readable suppressed (why it wasn't reported), readable warnings (what couldn't be parsed).
- Pairs with
compare_pptx(baseline regression) for "did the new change introduce new problems" regression. - Pairs with
deck render_with_reportfor "HTML→PPTX generate-and-gate".
Installation & dependencies
The audit core does not depend on Microsoft Office (pure parsing, no COM). Reading .pptx
needs python-pptx:
pip install "offipy[deck]" # includes python-pptx
import offipy / import offipy.audit do not load python-pptx (hard lazy-import constraint);
it is only needed when actually parsing a file.
Quick start
# text report (default)
offipy audit deck.pptx
# report + gate: exit code 1 when reaching HIGH (for CI)
offipy audit deck.pptx --fail-on HIGH
# JSON / Markdown / single-file HTML (SVG canvas, filterable)
offipy audit deck.pptx --format markdown
offipy audit deck.pptx --format html --out audit.html --slides-dir export/
from offipy import audit_pptx
report = audit_pptx("deck.pptx")
print(report.max_severity) # Severity.HIGH / MID / LOW / None
print(report.to_markdown())
for f in report.findings:
print(f.rule_id, f.severity.name, f.message)
CLI reference
offipy audit <file.pptx>
--format text|json|markdown|html default text (html writes <stem>.audit.html by default)
--out PATH report output file; text/json/markdown default to stdout
--fail-on HIGH|MID|LOW audit reaching that severity → exit 1 (default HIGH)
--baseline PATH when given, run regression comparison (see audit-baseline.en.md)
--fail-on-new HIGH|MID|LOW compare mode: candidate added/worsened reaching that severity → exit 1
--safe-margin FLOAT safe margin (inches, default 0.2)
--bounds-tolerance FLOAT out-of-bounds tolerance (inches, default 0.01)
--no-full-bleed-ignore disable full-page background suppression
--no-repeated-decoration-ignore disable repeated-decoration suppression
--no-page-number-ignore disable page-number suppression
--no-header-footer-ignore disable header/footer suppression
--slides-dir PATH html only: PNG slide background directory (slide-<n>.png)
--show-suppressed suppressed items are listed by default; flag kept for compatibility
--debug print full traceback on failure
Exit codes:
| code | meaning |
|---|---|
| 0 | threshold not reached (audit passed) |
| 1 | succeeded but reached --fail-on / --fail-on-new (gate hit) |
| 2 | argument or input error (missing file, misused --fail-on-new, etc.) |
| 3 | dependency or parse error (missing python-pptx, corrupted ZIP/XML) |
_audit_main catches all expected exceptions and converts them to exit codes, never colliding
with the other commands' OffipyError → 1.
Python API
from offipy import (
Severity, AuditConfig, AuditFinding,
PptxAuditReport, PptxDiffReport, audit_pptx, compare_pptx,
)
report = audit_pptx(
"deck.pptx",
AuditConfig(
safe_margin_in=0.2, # safe margin (inches)
bounds_tolerance_in=0.01, # out-of-bounds tolerance (inches)
ignored_shapes={(1, 42)}, # explicit user suppression: slide 1 shape #42
ignored_regions=[(0.0, 6.5, 10.0, 1.0)], # suppress a whole bottom region (inches x,y,w,h)
),
)
Severity
LOW=1 / MID=2 / HIGH=3 (IntEnum). Comparisons must use integer values
(f.severity >= Severity.HIGH); string comparison is forbidden. Serialized output is
"LOW"/"MID"/"HIGH".
Stable rule_id
| rule_id | severity | meaning |
|---|---|---|
geometry.bounds.partial |
MID / HIGH | shape partially out of slide bounds |
geometry.bounds.off_canvas |
LOW / MID | fully off-canvas (staging / animation / design residue) |
geometry.margin.left/right/top/bottom |
LOW | content near an edge, spacing < safe margin |
geometry.overlap.partial |
LOW / MID | shapes partially overlap (coverage ratio > 0.5, by smaller shape) |
geometry.overlap.covered_text |
MID / HIGH | one shape fully covers another; text covered by picture/chart is HIGH |
text.fit.horizontal |
LOW / MID | text exceeds the text box horizontally (explicit wrap="none" single line too wide) |
text.fit.vertical |
LOW / MID | text exceeds the text box vertically (explicit multi-line too tall / no available space) |
text.autofit.shrink |
MID / HIGH | normAutofit shrinks the font, possibly below the 8pt minimum readable |
text.autofit.grow |
MID / HIGH | spAutoFit grows the shape, possibly out of bounds / colliding |
Report models
PptxAuditReport:max_severity(Nonewith no findings),findings/suppressed/warnings/shapes(per-shape geometry snapshot),to_dict()/to_json()/to_markdown()/to_html(slides_dir=...). JSON output is fully safe (no Enum / Path / set).AuditFinding:rule_id/kind/severity/message/primary/secondary(two shape refs) /details/confidence.messageis Chinese natural language;rule_idis the stable key.- Coordinate unit: slide-absolute inches (group children already absolutized).
Rules
A registry drives the rules (DEFAULT_RULES), executed in order:
Bounds → Margin → Overlap → TextFit → Autofit.
Bounds
- Any edge beyond the page and past
bounds_tolerance_in→geometry.bounds.partial. - Large overshoot (
out_ratio > 0.5or max overshoot > 0.25× the page long edge) → HIGH, else MID. - No intersection with the canvas →
geometry.bounds.off_canvas(area ≥1 in² → MID, else LOW). - A shape already flagged for bounds on an edge is not flagged for the same-direction margin (no double report).
- Hidden / group / geometrically unresolvable objects are skipped.
Margin
safe_margin_in=0.2; ordinary content spacing < 0.2 →geometry.margin.*LOW.- Suppressions (into suppressed with a reason): full-page background (
full_bleed), page number (page_number), header/footer (header_footer), repeated decoration (repeated_decoration), user ignore (user_shape/user_region). - Connectors / hidden / group are skipped.
Overlap
- Per-page bbox quick-reject + O(n²);
ratio = overlap_area / min(shape areas), reported only when > 0.5. - Skipped: connectors / hidden / group / full-page background / tiny decoration points (area < 0.0025 in²) / parent-child·ancestor pairs / geometrically unresolvable.
- Pair classification: text inside a filled AutoShape → card container, suppressed as
intentional_containment; text fully covered by a picture/chart → HIGH; same Group with a reasonable z-order → downgraded severity. covered_textfires only when the covered shape has text — an empty box / decorative dot floating in an empty card (neither has text) is not reported.- Rotated shapes use AABB approximation (confidence 0.5, message notes "rotated bounding-box approximation").
TextFit
- Available area is reduced by the TextFrame's margins first.
- Horizontal overflow is reported only for text boxes with explicit
wrap="none"(single line, no wrapping);square(including an absent bodyPr@wrap, PowerPoint's default auto-wrap) never reports horizontal overflow. Paragraphs witha:brsoft breaks use the widest segment, not the cross-segment sum. Vertical overflow is reported by explicit line-count × line-height; line height reads the paragrapha:lnSpc(spcPtsabsolute /spcPctpercentage), falling back tofont-size × 1.2when unset. A trailing soft break (a:br) does not count as an extra line. - Overflow must exceed a 1pt noise floor (Pillow FreeType vs PowerPoint DirectWrite metrics differ at sub-pt level).
- Font metrics: fontTools parses the font file first (
.ttf/.ttc, such as Microsoft YaHeimsyh.ttc/msyhbd.ttc), summing hmtx glyph widths + kerning/GPOS pair adjustment → confidence 0.8; on failure it falls back to Pillowgetlength, then to character weights (CJK=1.0 / ASCII=0.5 / space=0.35 → confidence 0.4, message notes "low-confidence character estimation"). - Page-number / header / footer small text is skipped (naturally compact).
Autofit
The two modes are kept separate (not uniformly downgraded):
- normAutofit (shrink font to fit shape) → text.autofit.shrink: records original font size /
fontScale / estimated size; estimated size < 8pt → HIGH.
- spAutoFit (grow shape to fit text) → text.autofit.grow: HIGH if the grown shape may overflow,
else MID.
Roles & suppressions (suppressed)
suppressed is a suppression record with a reason, not silently dropped. Common reasons:
| reason | trigger |
|---|---|
full_bleed |
full-page background (≥90% coverage + near-center + low z-order + no text) |
page_number |
pure number + bottom 15% region + small size (or slide-number placeholder) |
header_footer |
header/footer placeholder; or repeated >60% across pages + top/bottom region |
repeated_decoration |
decoration whose fingerprint repeats >60% of pages |
intentional_containment |
text inside a filled AutoShape (card container) |
decorative_overlay |
small solid decoration/color bar on a text-bearing container (size-based) — no content occluded |
text_on_background |
text floating on a no-text background/container (non-contained) — nothing below to occlude |
transparent_overlay |
transparent (a:noFill) textless top shape — visually occludes nothing |
decorative_layering |
textless long-strip decoration layering (short side ≥3×, longer dimension ≥70% of the larger shape, non-contained) — partial overlap, nothing occluded |
user_shape / user_region |
user explicitly suppressed via ignored_shapes / ignored_regions |
Overlap occlusion is judged by "does the top shape actually cover content below": a transparent textless top shape occludes nothing, small decorations are size-based, and text on a no-text background occludes nothing. A text-bearing top shape (transparent or not) is never exempted — text-on-text is a real problem; transparency does not remove the content conflict.
Not all pure-number short text is globally ignored — only small pure numbers in the bottom region count as page numbers.
confidence semantics
| confidence | meaning |
|---|---|
| 1.0 | exact geometry, no heuristics |
| 0.8 | fontTools font metrics (hmtx glyph widths + kerning/GPOS pair adjustment, incl. .ttc collections) |
| 0.5 | AABB approximation of a rotated shape (message notes it) |
| 0.4 | character-weight fallback (message notes "low-confidence character estimation") |
warnings (parse exceptions)
warnings record items the parse layer cannot handle precisely: group.no_transform
(group missing a:xfrm → children cannot be precisely positioned → rules requiring exact
position are skipped), and audit.extract.slidesize_corrupt (presentation-level
sldSz@cx/@cy non-numeric → slide size falls back to 0.0, extraction continues).
These cases are not silently treated as zero rotation.
Known limitations & false-positive control
- text-fit does not run checks on table cells / SmartArt / text inside charts /
WordArt / vertical text / complex bullets and custom line spacing — they are silently
skipped (no hard-fail, no false positive). Structured unsupported warnings
(
textfit.table_unsupportedetc.) are not implemented; no timeline is committed. - Rotated groups / flips use AABB approximation for bounds/margin/overlap (the geometric shape occupies the same place; flipping only affects content orientation).
- No promise of zero false positives on every PPT — the fixed verification corpus
(
tests/fixtures/audit/) guarantees connector / hidden / rotate / flip / group are not misjudged, full-page background / page numbers do not false-report margin, and reasonable card containment does not false-report overlap; the rest is tuned withignored_shapes/ignored_regions/--no-*-ignore.
Deck generation gate (render_with_report)
After HTML→PPTX rendering, audit immediately and decide release by mode:
from offipy import deck
result = deck.render_with_report(
"deck.html", audit_mode="strict", fail_on=deck.Severity.HIGH,
)
# passed: replaces the target and returns a RenderResult (output_path + audit_report)
# failed: raises deck.AuditGateError (report on the exception, temp file cleaned, old target untouched)
offipy deck make --html deck.html --out deck.pptx --no-open \
--audit-mode strict --fail-on HIGH --audit-report deck.audit.json
report(default): render → audit → replace → returnRenderResult.strict: render → audit → max severity ≥fail_on→ raiseAuditGateError(report is written to disk first, the old.pptxis not corrupted); otherwise → replace.- Atomic replace: conversion writes a same-directory temp file first; only replace with
os.replaceafter the audit passes.
CI usage
# block: any HIGH issue
offipy audit deck.pptx --fail-on HIGH
# regression: only block candidate added/worsened MID+ issues (pre-existing baseline issues pass)
offipy audit candidate.pptx --baseline baseline.pptx --fail-on-new MID
Hand results to downstream with --format json; rule_id is the stable machine key,
message is for humans only.