"""Model-free audit of an assembled deck: what a reviewer would catch by eye.
:mod:`md2star.pptx_fit` decides, before assembly, whether a slide's content
*should* fit a candidate layout. This module asks the complementary question
*after* assembly, of the saved ``.pptx`` itself: given the shapes that actually
exist, with the geometry and the font sizes they actually carry, is anything
wrong with this deck? Six checks, all arithmetic on OOXML — no model, no
render, no network:
``off-slide``
A shape extends past the canvas. Content that nobody will ever see.
``overlap``
Two text shapes cover the same region, or a picture covers part of a text
shape: words with a photograph laid over them (or under them) are as
unreadable as words over words. A picture filling most of the canvas
(:data:`BACKDROP_SHARE`) is a backdrop, the photo-hero composition, and
is not counted. A one-glyph ornament (:func:`md2star.pptx_assemble.is_ornament`) does not
count as one of the two — a "01" inside a title's generous box is a
designer's composition too, and cloned pages are full of them.
``ghost-placeholder``
A placeholder the slide inherited from its layout and never filled. The
assembler removes these (see
:func:`md2star.pptx_assemble.drop_empty_placeholders`); this is the
invariant re-checked on the file, which is the only place it matters.
``illegible-type``
A run below :data:`LEGIBLE_MIN_PT`. Not the 30 pt ideal — the size below
which a projected slide stops being readable at all.
``low-contrast``
Text a reader cannot separate from what is behind it, by the WCAG ratio
(sRGB-linearised relative luminance), against the 3:1 floor large type
earns and the 4.5:1 floor small type does not. The backdrop is resolved
the way a renderer resolves it — shapes under the text, then the slide's
background, then the layout's shapes and background, then the master's —
because on a real template the colour panel a heading sits on is a shape
on the *layout* while the layout's own background is the other half of the
slide. Only runs that state their own colour are judged, and a backdrop
this module cannot read (a photograph, a gradient) is left unjudged rather
than guessed at.
``under-min-pt``
Only with ``min_pt`` (``md2star audit --min-pt 30``): slide *content*
set under that size — Kawasaki's 30 pt, enforced instead of observed.
Footer chrome is not content: date / footer / slide-number placeholders,
any shape holding a field (``<a:fld>``, e.g. the page number), and a
single line of text in the bottom 12 % of the slide are exempt.
A photo credit (a shape named ``md2star-credit``, see
:func:`md2star.pptx_assemble.add_credit`) is exempt from this, from
``illegible-type``, ``overrun`` and ``overlap``: it is small and over its
picture by design. Its contrast is still judged.
``overrun``
The text needs more room than its box gives it at the size it declares.
Reported with the size that *would* fit, because PowerPoint's auto-shrink
means the consequence is quiet (smaller type), not visible (clipped text).
An observation about the deck's density, in the same spirit as
:data:`md2star.pptx_fit.KAWASAKI_MIN_PT`: a finding, never an error.
Why bother, when :func:`md2star.pptx_assemble.eyeball_slides` already shows
each slide to a vision model? Because that loop needs a resolved VLM,
LibreOffice and poppler, while every failure above is *measurable*.
A geometry bug deserves arithmetic, not an opinion — and this way the quality
gate still runs on a laptop with no model at all, which is md2star's whole
offline-by-default posture. The dimensions are the ones PPTEval scores a deck
on by eye (Design: "proper layout ... element placement, and absence of
overlaps", arXiv 2501.03936); this is the half of that rubric a machine can
settle on its own.
Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""
from __future__ import annotations
import math
import sys
from dataclasses import dataclass
from pathlib import Path
from pptx import Presentation
from pptx.dml.color import MSO_COLOR_TYPE
from pptx.enum.dml import MSO_FILL
from pptx.enum.shapes import MSO_SHAPE_TYPE
from .i18n import gettext as _
from .i18n import ngettext as _n
from .logging import get_logger
# One-way dependency, on purpose: the assembler owns the placeholder vocabulary
# (it is what writes placeholders), the audit only reads it. Should the eyeball
# loop ever want to consume this module, it must import it inside the function
# — as md2star/cli.py does — rather than at module scope.
from .pptx_assemble import (
CREDIT_NAME,
LATENT_KINDS,
declared_style,
is_ornament,
placeholder_kind,
)
from .pptx_fit import AVG_CHAR_WIDTH_EM, EMU_PER_PT, FIT_SLACK, LINE_SPACING
logger = get_logger(__name__)
#: Point size below which a projected slide stops being readable from the back
#: of a room. Deliberately far below :data:`md2star.pptx_fit.KAWASAKI_MIN_PT`
#: (30 pt): that is the *ideal* this project reports against, while this is the
#: floor under which something has gone wrong — a caption nobody can read.
LEGIBLE_MIN_PT = 12.0
#: WCAG 2.x calls type *large* at 18 pt, or 14 pt when bold, and asks 3:1 of
#: it against its background instead of the 4.5:1 it asks of body copy. A
#: slide is mostly large type by that definition, which is why the floor here
#: is usually the lenient one — and why a run that falls under it is a real
#: problem rather than a strict reading of a web rule.
CONTRAST_LARGE_PT = 18.0
CONTRAST_LARGE_BOLD_PT = 14.0
CONTRAST_MIN_LARGE = 3.0
CONTRAST_MIN_NORMAL = 4.5
#: How far past the canvas edge a shape may sit before it counts as bleeding
#: off the slide, as a fraction of the canvas. Designer templates routinely
#: bleed a background band a hair past the edge on purpose, and rounding EMU
#: through inches leaves sub-point residue, so an exact test would cry wolf.
BLEED_TOLERANCE = 0.01
#: Shared area, as a fraction of the smaller shape's own area, above which two
#: text shapes are judged to collide. A few percent is a touching corner; a
#: tenth of a shape is a legibility problem.
OVERLAP_TOLERANCE = 0.10
#: Share of the canvas above which a picture is a backdrop (a full-bleed photo
#: with a title on it, by design) rather than a picture placed beside words.
BACKDROP_SHARE = 0.8
#: Every finding kind this module can report, in descending severity. Used to
#: order a report and to keep the vocabulary closed (a typo'd kind in a caller
#: would otherwise sort and summarise silently).
KINDS: tuple[str, ...] = (
"off-slide",
"overlap",
"ghost-placeholder",
"illegible-type",
"under-min-pt",
"low-contrast",
"overrun",
)
#: Share of the slide's height, from the bottom, where a single line of text
#: is footer chrome (a course name, a page number) for ``under-min-pt``.
CHROME_BAND = 0.12
[docs]
@dataclass(frozen=True)
class Finding:
"""One thing wrong with one slide.
Attributes
----------
slide : int
1-based slide number, as PowerPoint shows it.
kind : str
One of :data:`KINDS`.
detail : str
A sentence a human can act on, with the measurement in it.
shape : str
The offending shape's name, when a single shape owns the finding.
measured_pt, fits_pt : float or None
An ``overrun``'s declared size and the size that would fit. ``None``
for every other kind, where the question does not arise.
"""
slide: int
kind: str
detail: str
shape: str = ""
#: For an ``overrun``: the size the text declares, and the size that would
#: fit. Carried as numbers, not only inside *detail*, because the eyeball
#: loop consumes this (see
#: :func:`md2star.pptx_assemble._measured_verdict`) and a loop that reads
#: them can apply the exact correction instead of nudging toward it.
measured_pt: float | None = None
fits_pt: float | None = None
def __str__(self) -> str:
where = f" [{self.shape}]" if self.shape else ""
# The kind keeps its English name inside a translated frame: it is the
# closed vocabulary (:data:`KINDS`) a caller greps and a bug report
# quotes, and a French build whose findings were named differently
# would be a different machine interface, not a translated one.
return _("slide %(n)d: %(kind)s%(where)s — %(detail)s") % {
"n": self.slide,
"kind": self.kind,
"where": where,
"detail": self.detail,
}
#: A ``<p:pic>``: a picture, whether added freely or filled into a placeholder.
_PIC_TAG = "{http://schemas.openxmlformats.org/presentationml/2006/main}pic"
# ── geometry helpers ─────────────────────────────────────────────────────────
def _box(shape) -> tuple[int, int, int, int] | None:
"""``(left, top, width, height)`` in EMU, or ``None`` when inherited.
A shape that takes its position from its layout reports ``None`` for these,
and python-pptx offers no resolution of the inheritance chain. Unknown
geometry is skipped rather than guessed: a false "off-slide" on a shape
whose real position is fine would train the reader to ignore the report.
"""
left, top, width, height = shape.left, shape.top, shape.width, shape.height
if None in (left, top, width, height) or width <= 0 or height <= 0:
return None
return (int(left), int(top), int(width), int(height))
def _intersection(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> int:
"""Area shared by two EMU boxes (0 when they do not meet)."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
dx = min(ax + aw, bx + bw) - max(ax, bx)
dy = min(ay + ah, by + bh) - max(ay, by)
return dx * dy if dx > 0 and dy > 0 else 0
def _inherited_pt(shape, level: int) -> float | None:
"""The size a placeholder's paragraph inherits from its layout / master."""
if not getattr(shape, "is_placeholder", False):
return None
try:
return declared_style(shape, level)[0]
except Exception: # noqa: BLE001 — an unreadable chain is "unknown", not an error
return None
def _runs_of(para, shape=None) -> list[tuple[str, float | None]]:
"""One paragraph's ``(text, point size)`` runs.
Paragraph-level size is the fallback for a run that declares none: that is
the next link in OOXML's own inheritance chain, and it keeps a uniformly
styled paragraph measurable. For a placeholder (*shape* given) the chain
continues through its layout and master (:func:`declared_style`) — the
sizes md2star's ``--min-pt`` deliberately leaves to the template, which an
audit that read only declared sizes would never check. ``None`` survives
only where nothing declares a size.
"""
para_pt = para.font.size.pt if para.font.size is not None else None
if para_pt is None and shape is not None:
para_pt = _inherited_pt(shape, para.level)
return [
(run.text, run.font.size.pt if run.font.size is not None else para_pt) for run in para.runs
]
def _runs(shape) -> list[tuple[str, float | None]]:
"""Every run in a shape, flattened."""
return [run for para in shape.text_frame.paragraphs for run in _runs_of(para, shape)]
def _needed_pt_height(shape, box: tuple[int, int, int, int]) -> tuple[float, float]:
"""Vertical points this shape's text needs, and the largest size in it.
Uses :mod:`md2star.pptx_fit`'s character model — 0.5 em mean advance, 1.2
line spacing — per paragraph, so a shape that mixes a 26 pt heading with
16 pt bullets is measured as the sum of what each actually costs rather
than at one averaged size.
"""
width_pt = box[2] / EMU_PER_PT
total = 0.0
largest = 0.0
for para in shape.text_frame.paragraphs:
runs = _runs_of(para, shape)
text = "".join(t for t, _ in runs)
sizes = [pt for _, pt in runs if pt]
if not text.strip() or not sizes:
continue # blank, or fully inherited sizing: unknowable, so uncounted
# The biggest run in a paragraph sets its line height, and (being the
# widest) its characters-per-line — the conservative choice of the two.
pt = max(sizes)
largest = max(largest, pt)
chars_per_line = max(1.0, width_pt // (pt * AVG_CHAR_WIDTH_EM))
total += math.ceil(len(text) / chars_per_line) * pt * LINE_SPACING
return total, largest
# ── the checks ───────────────────────────────────────────────────────────────
def _check_bounds(index: int, shape, box, canvas) -> list[Finding]:
"""Flag a shape that reaches past the canvas by more than the tolerance."""
slide_w, slide_h = canvas
left, top, width, height = box
tol_x, tol_y = slide_w * BLEED_TOLERANCE, slide_h * BLEED_TOLERANCE
# All four edges, in one finding: a shape pushed off the bottom-right
# corner is one mistake, and reporting it twice would read as two.
over = []
if left < -tol_x:
over.append(_("%(pt).0fpt past the left edge") % {"pt": (-left) / EMU_PER_PT})
if top < -tol_y:
over.append(_("%(pt).0fpt past the top edge") % {"pt": (-top) / EMU_PER_PT})
if left + width > slide_w + tol_x:
over.append(
_("%(pt).0fpt past the right edge") % {"pt": (left + width - slide_w) / EMU_PER_PT}
)
if top + height > slide_h + tol_y:
over.append(
_("%(pt).0fpt past the bottom edge") % {"pt": (top + height - slide_h) / EMU_PER_PT}
)
if not over:
return []
return [Finding(index, "off-slide", ", ".join(over), shape.name)]
def _check_overlaps(
index: int, texts: list[tuple[object, tuple[int, int, int, int]]]
) -> list[Finding]:
"""Flag pairs of text shapes that cover the same part of the slide."""
findings: list[Finding] = []
# Every unordered pair once: `texts[i + 1:]` rather than the full list, so
# a collision is one finding and not a mutual pair of them.
for i, (shape_a, box_a) in enumerate(texts):
for shape_b, box_b in texts[i + 1 :]:
shared = _intersection(box_a, box_b)
if not shared:
continue
# Measured against the SMALLER shape: a caption swallowed by a
# full-bleed text panel is a collision, even though the panel has
# barely noticed it. The larger shape's area would hide that.
smaller = min(box_a[2] * box_a[3], box_b[2] * box_b[3])
fraction = shared / smaller if smaller else 0.0
if fraction > OVERLAP_TOLERANCE:
findings.append(
Finding(
index,
"overlap",
_("%(a)r and %(b)r share %(share).0f%% of the smaller box")
% {"a": shape_a.name, "b": shape_b.name, "share": fraction * 100},
)
)
return findings
def _check_picture_overlaps(
index: int,
pictures: list[tuple[object, tuple[int, int, int, int]]],
texts: list[tuple[object, tuple[int, int, int, int]]],
canvas: tuple[int, int],
) -> list[Finding]:
"""Flag a picture that covers part of a text shape (backdrops excepted)."""
findings: list[Finding] = []
limit = canvas[0] * canvas[1] * BACKDROP_SHARE
for pic, pbox in pictures:
if pbox[2] * pbox[3] >= limit:
continue
for shape, tbox in texts:
shared = _intersection(pbox, tbox)
if not shared:
continue
smaller = min(pbox[2] * pbox[3], tbox[2] * tbox[3])
fraction = shared / smaller if smaller else 0.0
if fraction > OVERLAP_TOLERANCE:
findings.append(
Finding(
index,
"overlap",
_("picture %(a)r covers text %(b)r (%(share).0f%% of the smaller box)")
% {"a": pic.name, "b": shape.name, "share": fraction * 100},
pic.name,
)
)
return findings
# ── contrast: what a reader can actually read ────────────────────────────────
def _relative_luminance(rgb) -> float:
"""WCAG relative luminance of an ``RGBColor``, sRGB-linearised."""
channels = []
for raw in (rgb[0], rgb[1], rgb[2]):
c = raw / 255.0
channels.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
def _contrast_ratio(a, b) -> float:
"""Contrast ratio between two colours, 1:1 (identical) to 21:1 (b/w)."""
first, second = _relative_luminance(a), _relative_luminance(b)
lighter, darker = max(first, second), min(first, second)
return (lighter + 0.05) / (darker + 0.05)
def _solid_rgb(shape):
"""The shape's own solid fill as an ``RGBColor``, or ``None``.
``None`` covers everything this check cannot responsibly resolve: a
gradient, a pattern, a theme reference, a picture fill, and a shape with no
fill of its own. The caller treats that as "keep looking", never as "this
is white".
"""
try:
fill = shape.fill
if fill.type != MSO_FILL.SOLID:
return None
return fill.fore_color.rgb
except Exception: # noqa: BLE001 — a picture/graphic frame has no fill at all
return None
def _covers(outer: tuple[int, int, int, int], inner: tuple[int, int, int, int]) -> bool:
"""Whether *outer* contains *inner* whole.
Containment, not overlap, and deliberately: a text box lying across two
backgrounds — half on a designer's colour panel, half on the dark slide —
has no single contrast ratio, and inventing one would be worse than saying
nothing. Those are left unjudged.
"""
return (
outer[0] <= inner[0]
and outer[1] <= inner[1]
and outer[0] + outer[2] >= inner[0] + inner[2]
and outer[1] + outer[3] >= inner[1] + inner[3]
)
def _backdrop_in(shapes, box, *, below=None):
"""Topmost resolvable colour behind *box* among *shapes*.
Walks back-to-front like a renderer does, so the nearest thing under the
text wins. Returns ``(rgb, True)`` when a colour was found, ``(None,
False)`` when nothing here is behind it, and ``(None, True)`` when
something is — a picture, a gradient — that this check cannot read: that
is a "stop looking", because whatever is further back is not what the
reader sees.
"""
ordered = list(shapes)
if below is not None:
ordered = ordered[: ordered.index(below)]
for candidate in reversed(ordered):
candidate_box = _box(candidate)
if candidate_box is None or not _covers(candidate_box, box):
continue
rgb = _solid_rgb(candidate)
if rgb is not None:
return rgb, True
if candidate.shape_type == MSO_SHAPE_TYPE.PICTURE:
return None, True # a photograph: unknowable, and not worth guessing
return None, False
def _background_of(part):
"""A slide/layout/master's own background colour, or ``None``."""
try:
fill = part.background.fill
return fill.fore_color.rgb if fill.type == MSO_FILL.SOLID else None
except Exception: # noqa: BLE001 — no explicit background is the common case
return None
def _backdrop(slide, shape, box):
"""What a reader sees behind *shape*, resolved the way a renderer would.
Shapes under it on the slide, then the slide's own background, then the
layout's shapes and background, then the master's. The chain matters: on a
real designer template the colour panel a title sits on is a shape **on the
layout**, while the layout's own background is the dark half of the slide —
read only the latter and a black heading on a lime panel looks like black
on black.
"""
rgb, blocked = _backdrop_in(slide.shapes, box, below=shape)
if rgb is not None or blocked:
return rgb
own = _background_of(slide)
if own is not None:
return own
layout = slide.slide_layout
rgb, blocked = _backdrop_in(layout.shapes, box)
if rgb is not None or blocked:
return rgb
inherited = _background_of(layout)
if inherited is not None:
return inherited
master = layout.slide_master
rgb, blocked = _backdrop_in(master.shapes, box)
if rgb is not None or blocked:
return rgb
return _background_of(master)
def _check_contrast(index: int, slide, shape, box) -> list[Finding]:
"""Flag text a reader cannot separate from what is behind it.
Only runs that state their own colour are judged. A run that inherits one
from the theme is left alone for the same reason an inherited size is: the
value comes from the designer's master, and guessing at it would invent
findings rather than report them. That is the known limit of this check,
and it is a limit in the conservative direction.
"""
# A shape with its own fill (a code panel) is its text's backdrop.
behind = _solid_rgb(shape) or _backdrop(slide, shape, box)
if behind is None:
return []
worst: tuple[float, float, object] | None = None
for paragraph in shape.text_frame.paragraphs:
for run in paragraph.runs:
if not run.text.strip():
continue
colour = run.font.color
try:
rgb = colour.rgb if colour.type == MSO_COLOR_TYPE.RGB else None
except Exception: # noqa: BLE001 — an unresolvable colour is not a finding
rgb = None
if rgb is None:
continue
size = run.font.size.pt if run.font.size else LEGIBLE_MIN_PT
large = size >= CONTRAST_LARGE_PT or (run.font.bold and size >= CONTRAST_LARGE_BOLD_PT)
floor = CONTRAST_MIN_LARGE if large else CONTRAST_MIN_NORMAL
ratio = _contrast_ratio(rgb, behind)
if ratio < floor and (worst is None or ratio < worst[0]):
worst = (ratio, floor, rgb)
if worst is None:
return []
ratio, floor, rgb = worst
return [
Finding(
index,
"low-contrast",
_("#%(fg)s on #%(bg)s is %(ratio).1f:1, under the %(floor)g:1 floor")
% {"fg": rgb, "bg": behind, "ratio": ratio, "floor": floor},
shape.name,
)
]
def _check_type(index: int, shape) -> list[Finding]:
"""Flag runs below the legibility floor."""
# A set, then sorted: one finding per shape listing each distinct offending
# size, rather than one per run — a 40-line 8 pt table is a single problem.
tiny = sorted({pt for text, pt in _runs(shape) if text.strip() and pt and pt < LEGIBLE_MIN_PT})
if not tiny:
return []
sizes = ", ".join(f"{pt:g}pt" for pt in tiny)
# One message with the sizes pre-joined: a language that needs a different
# separator can still change the sentence around them, and the list itself
# is numbers, which read the same everywhere.
return [
Finding(
index,
"illegible-type",
_("%(sizes)s is under the %(floor)gpt legibility floor")
% {"sizes": sizes, "floor": LEGIBLE_MIN_PT},
shape.name,
)
]
def _is_chrome(shape, box, canvas) -> bool:
"""Footer furniture rather than slide content (see ``under-min-pt``)."""
if shape.is_placeholder and placeholder_kind(shape) in LATENT_KINDS:
return True
if (
shape.element.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}fld")
is not None
):
return True
if box is None:
return False
lines = [p for p in shape.text_frame.paragraphs if "".join(r.text for r in p.runs).strip()]
single = len(lines) <= 1 and "\x0b" not in shape.text_frame.text
return single and box[1] >= canvas[1] * (1 - CHROME_BAND)
def _check_min_pt(index: int, shape, box, canvas, min_pt: float) -> list[Finding]:
"""Flag content text set under *min_pt* (``md2star audit --min-pt``)."""
if _is_chrome(shape, box, canvas):
return []
small = sorted({pt for text, pt in _runs(shape) if text.strip() and pt and pt < min_pt})
if not small:
return []
return [
Finding(
index,
"under-min-pt",
_("%(sizes)s is under the %(floor)gpt minimum")
% {"sizes": ", ".join(f"{pt:g}pt" for pt in small), "floor": min_pt},
shape.name,
)
]
def _check_overrun(index: int, shape, box) -> list[Finding]:
"""Flag text that needs more vertical room than its box gives it."""
needed, largest = _needed_pt_height(shape, box)
available = box[3] / EMU_PER_PT
if needed <= 0 or available <= 0 or needed <= available * FIT_SLACK:
return []
# Capacity scales with the square of the point size (line height *and*
# characters per line both do), so the size that would fit is the current
# one scaled by the square root of the shortfall — the same arithmetic as
# pptx_fit.FitReport.implied_pt, applied to a real shape.
implied = largest * math.sqrt(available / needed)
return [
Finding(
index,
"overrun",
_("text needs %(times).1fx its box at %(pt)gpt (fits at ~%(fits).0fpt)")
% {"times": needed / available, "pt": largest, "fits": implied},
shape.name,
measured_pt=largest,
fits_pt=implied,
)
]
# ── the audit ────────────────────────────────────────────────────────────────
[docs]
def audit(
deck: Path, *, canvas: tuple[int, int] | None = None, min_pt: float | None = None
) -> list[Finding]:
"""Audit a saved ``.pptx``, slide by slide.
Parameters
----------
deck : pathlib.Path
The assembled presentation to inspect. Read-only; nothing is written.
canvas : tuple of int, optional
``(width, height)`` in EMU to measure against. Defaults to the deck's
own slide size, which is what you want unless you are checking a deck
against a different target canvas.
min_pt : float, optional
Also report content text under this size (``under-min-pt``).
Returns
-------
list of Finding
Sorted by slide, then by descending severity (:data:`KINDS` order).
Empty means the deck passed every check.
Examples
--------
>>> findings = audit(Path("deck.pptx")) # doctest: +SKIP
>>> print(summarise(findings)) # doctest: +SKIP
layout audit: clean (12 slides)
"""
prs = Presentation(str(deck))
size = canvas or (prs.slide_width, prs.slide_height)
findings: list[Finding] = []
for index, slide in enumerate(prs.slides, start=1):
# Collected across the slide, because an overlap is a property of a
# PAIR: it cannot be judged while visiting either shape alone.
texts: list[tuple[object, tuple[int, int, int, int]]] = []
pictures: list[tuple[object, tuple[int, int, int, int]]] = []
for shape in slide.shapes:
box = _box(shape)
# Bounds first, and for pictures too: an image half off the canvas
# is the same defect as a text box half off it.
if box is not None:
findings += _check_bounds(index, shape, box, size)
if shape.element.tag == _PIC_TAG:
pictures.append((shape, box))
if shape.element.tag == _PIC_TAG:
continue # a picture, filled into its placeholder or not: no text to judge
if not shape.has_text_frame or not shape.text_frame.text.strip():
# An EMPTY placeholder is the ghost the assembler should have
# removed; an empty plain shape is a designer's decoration, and
# an empty date/footer/slide-number one is PowerPoint's to fill.
if shape.is_placeholder and placeholder_kind(shape) not in LATENT_KINDS:
findings.append(
Finding(
index,
"ghost-placeholder",
_("unfilled placeholder"),
shape.name,
)
)
continue
if shape.name == CREDIT_NAME:
# A photo credit (pptx_assemble.add_credit): small on purpose,
# over its picture on purpose, like a footer. Only whether a
# reader can still see it is judged.
if box is not None:
findings += _check_contrast(index, slide, shape, box)
continue
findings += _check_type(index, shape)
if min_pt is not None:
findings += _check_min_pt(index, shape, box, size, min_pt)
if box is not None:
findings += _check_overrun(index, shape, box)
findings += _check_contrast(index, slide, shape, box)
if not is_ornament(shape.text_frame.text.strip()):
# A one-glyph ornament — the "01" of a numbered step, an
# arrow — sitting inside a title's generous box is the
# designer's own composition, not two blocks of text
# colliding. Same exemption, and same reason, as text
# over a picture.
texts.append((shape, box))
findings += _check_overlaps(index, texts)
findings += _check_picture_overlaps(index, pictures, texts, size)
# Slide order first (a reader fixes a deck front to back), severity within
# a slide, then shape name so two runs over one deck report identically.
order = {kind: i for i, kind in enumerate(KINDS)}
findings.sort(key=lambda f: (f.slide, order.get(f.kind, len(KINDS)), f.shape))
return findings
[docs]
def summarise(findings: list[Finding], *, slides: int | None = None) -> str:
"""One line fit for stdout: what the audit found, or that it found nothing.
Parameters
----------
findings : list of Finding
As returned by :func:`audit`.
slides : int, optional
Deck length, for the "clean" wording.
Returns
-------
str
A single line, never empty.
Examples
--------
>>> summarise([], slides=12)
'layout audit: clean (12 slides)'
>>> summarise([Finding(3, "overlap", "x")])
'layout audit: 1 finding on 1 slide (1 overlap)'
"""
if not findings:
if slides:
return _("layout audit: clean (%(slides)d slides)") % {"slides": slides}
return _("layout audit: clean")
counts = {kind: sum(1 for f in findings if f.kind == kind) for kind in KINDS}
# The kind names are the closed machine vocabulary (:data:`KINDS`) and stay
# in English in every language: they are what a caller greps and what an
# issue report quotes. Only the sentence around them is translated.
breakdown = ", ".join(f"{n} {kind}" for kind, n in counts.items() if n)
affected = len({f.slide for f in findings})
# Two separate plural lookups rather than one sentence with two numbers:
# "3 findings on 1 slide" is a real outcome, so each count has to choose
# its own form — and gettext's plural rules work on one number at a time.
things = _n("%(n)d finding", "%(n)d findings", len(findings)) % {"n": len(findings)}
where = _n("%(n)d slide", "%(n)d slides", affected) % {"n": affected}
return _("layout audit: %(things)s on %(where)s (%(breakdown)s)") % {
"things": things,
"where": where,
"breakdown": breakdown,
}
[docs]
def log_findings(findings: list[Finding]) -> None:
"""Send each finding to the logger at a level that matches its severity."""
for finding in findings:
# An overrun is a density observation (PowerPoint will auto-shrink the
# text), so it is information; everything else is a real defect.
level = logger.info if finding.kind == "overrun" else logger.warning
level("%s", finding)
# ── `md2star audit` ──────────────────────────────────────────────────────────
#: The kinds that are defects rather than observations, and so decide the exit
#: status. An overrun is left out on purpose: PowerPoint auto-shrinks the text,
#: so a dense deck is a choice, not a breakage. `--strict` counts it too.
DEFECT_KINDS = frozenset(KINDS) - {"overrun"}
[docs]
def run(
deck_path: str, *, as_json: bool = False, strict: bool = False, min_pt: float | None = None
) -> int:
"""``md2star audit <deck.pptx>`` — measure a deck and report what is wrong.
Works on **any** presentation, not only the ones md2star built: everything
it checks is read off the OOXML, so a deck somebody made in PowerPoint is
just as auditable. No model, no render, no network.
The flags arrive parsed: since 3.10.0 this package has exactly one command
line, built with click in :mod:`md2star.cli`, so nothing here re-parses an
argv.
Parameters
----------
deck_path : str
The ``.pptx`` to inspect. Never modified.
as_json : bool, optional
Emit the findings as JSON instead of one line each plus a summary.
strict : bool, optional
Also fail on ``overrun``, which is otherwise an observation.
min_pt : float, optional
Fail on content text under this size (``under-min-pt``); footer
chrome is exempt.
Returns
-------
int
``0`` when nothing but observations were found, ``1`` when a defect
was (see :data:`DEFECT_KINDS`, and *strict*), ``2`` on a usage or
read error.
"""
import json
deck = Path(deck_path).expanduser()
# Checked before opening: python-pptx reports a missing file as
# "Package not found", which reads like a corrupt deck rather than a typo.
if not deck.is_file():
print(f"md2star audit: no such file: {deck}", file=sys.stderr)
return 2
try:
findings = audit(deck, min_pt=min_pt)
except Exception as exc: # noqa: BLE001 — any reader error is the same answer
# python-pptx raises several unrelated types for "that is not a pptx"
# (PackageNotFoundError, KeyError, BadZipFile); the user needs one
# sentence, not the taxonomy.
print(f"md2star audit: cannot read {deck}: {exc}", file=sys.stderr)
return 2
slides = len(Presentation(str(deck)).slides)
if as_json:
# Null-free: `measured_pt` / `fits_pt` only exist on an overrun, and a
# consumer should see a key when it means something rather than a
# column of nulls on every other kind.
payload = [{k: v for k, v in vars(f).items() if v is not None} for f in findings]
print(json.dumps(payload, indent=2, ensure_ascii=False))
else:
for finding in findings:
print(finding)
print(summarise(findings, slides=slides))
failing = DEFECT_KINDS | ({"overrun"} if strict else set())
return 1 if any(f.kind in failing for f in findings) else 0