The deckwright qa command checks a built deck (a .pptx plus the build manifest
bin/run build writes alongside it) against the automated checks below and writes a
findings report. This is a distinct layer from the "render and eyeball it" QA
described in docs/pptx-deck-building.md — that loop is a human looking at
contact_sheet.png; this one is a machine reading the manifest and, optionally,
the rendered PDF text. Run both. Neither replaces the other — see "What this
layer cannot catch" below for exactly why.
For AI assistants: pptx-deck-building.md covers the render/QA loop end
to end; this doc is the full reference for the automated qa command
specifically.
- Running it
- The checks
- What the manifest records
- Severity and
--fail-on - Reading
qa.mdandqa.json - Env knobs
- What this layer cannot catch
- Adding a new check
bin/run build examples/feature-tour.deck.yaml
bin/run qa "out/feature-tour/deckwright Feature Tour v15.pptx"qa takes the path to a built .pptx. It looks for <deck>.manifest.json
next to it unless --manifest names a different file, and reads the theme
path the manifest itself recorded unless --theme overrides it. If the
manifest is missing, qa fails with a message telling you to build the deck
first — it never guesses or synthesizes one. A deck built by hand outside the
compiler (no manifest) cannot be QA'd; build it with deckwright build or accept
that this layer has nothing to check. The package checks are the exception —
they read the .pptx itself, so they stay honest even when the manifest is
stale from a hand-edit.
Every check but two runs off the manifest or the saved file alone and is
effectively instant. overflow renders the deck with LibreOffice and extracts text with
Poppler's pdftotext; render-contrast reads the pixels of that same render. Both cost
it, and --no-render skips both when you only want the fast geometry/contrast pass
(e.g. a pre-commit hook).
The render is reused when one already matches. render records the deck's SHA-256 and a
digest of every file it wrote in .build/render.json; when the deck on disk has those bytes
and the PDF and page images are all still there unchanged, at the configured DPI or finer,
qa checks them instead of converting again. bin/run render followed by bin/run qa
therefore converts the deck once. Any edit to the deck starts a fresh render.
--outdir controls where qa.md / qa.json land (default <deck-dir>/render/<deck>).
| Check | Reads | Catches |
|---|---|---|
bounds |
manifest only | A shape's declared box extends past the slide edges. |
placement-fit |
manifest only | A shape's declared box escapes the rect its own placement was given — an overrun onto the neighbouring placement, which bounds passes because the shape is still on the slide. |
reserved |
manifest only | A shape's declared box intrudes on one of the theme's reserve: regions (e.g. the logo wedge). |
min-font |
manifest only | A shape's declared font size is below the theme's minimum. A chart's data labels, axis text and legend are recorded as parts of its frame, so they are held to it too. |
contrast |
manifest only | A shape's declared foreground/background pair fails WCAG AA (4.5:1 normal text, 3.0:1 at 18pt+). Severity follows certainty: a shortfall above 3:1 warns, because the manifest records the pair a component asked for and the real backdrop may be better; below 3:1 nothing behind the text saves it, so it is an error. The build itself never refuses on contrast — it logs theme_pair_below_aa and carries on, so a brand's own palette is never unbuildable over a check this layer runs better. Chart text is checked as written into the chart part: each label's ink against the worst stop of the fill it sits on, or against the paper behind the chart when it sits beside its shape. |
text-fit |
manifest only | A shape whose own recorded text needs more height than the box it declared — text running past its own frame, which bounds structurally cannot see. Each line is measured at its own recorded size, and the space recorded after each paragraph but the last is added to it, so a list whose items wrap past the bottom of their column is reported. |
fill-ground |
manifest only | A shape filled to stand off what is behind it — an inverse or accent panel, card or ellipse, a versus plate, a diverge bar, a callout's dot, a fanout source — that neither luminance (under 3:1) nor colour (under 35 ΔE) separates from the ground it was laid on, so it all but vanishes: an inverse bound to the page's own colour, an accent the page already is. A surface fill is a recess by design and records no fill to judge. WARN. |
placeholder |
manifest only | Recorded text that reads like copy nobody meant to ship — four phrases deckwright new seeds, plus lorem, ipsum, TODO, FIXME, [insert, and a run of three or more x in either case. Bare capitalised TODO fires unless whitespace and two more capitals follow it — the one exception, and what spares the Spanish TODO EL MUNDO; TODO el mundo and TODO, EL MUNDO are both reported, the second because a comma is not whitespace. TODO: fires unconditionally. Lowercase todo fires only as todo: opening a line, or as @todo anywhere. Every recorded row is read — its lines, or its text where it records none — and so are the slide's speaker notes. TBD is deliberately not matched: a deck may legitimately say it. WARN. |
overflow |
manifest + render | A line of text the manifest says a shape contains is missing from the rendered PDF's extracted text for that slide. A line the whole page does not hold is asked for again inside the shape's own box, because pdftotext merges side-by-side placements row by row and splices one column's line into the other's. A line on a plate — a code listing — is asked for inside that plate only, since text run off its plate is still on the page. Every box is read from one pdftotext -bbox pass over the render, made the first time a box is needed; where that pass fails no box holds anything, and a plated line is asked for on the page like any other. |
render-contrast |
the render's pixels | Text on a slide showing a picture — one this deck placed, or one the template paints behind every slide — whose rendered surroundings fall below WCAG AA. Measured in three horizontal bands per shape, so a gradient scrim is judged where it is weakest. |
font-substituted |
the render's PDF, else this machine | A face the deck's runs name that the render did not embed, so it is set in something else and every finding drawn from it — overflow, render-contrast — judged type the deck does not carry. Read from the fonts the rendered PDF embeds (pdffonts), matched through the PostScript names fontconfig gives each family; where pdffonts cannot run, from fc-list's installed families instead. The render hands LibreOffice every installed family the deck names (see cli.md), so on a machine with fontconfig a finding means the face is not installed there under that name. A run the render never draws — an equation's maths, set in the Choice branch PowerPoint reads — is not counted. One finding per face, on slide 0. Runs only when qa renders. WARN. |
cjk-unrendered |
the render's PDF | A slide carrying Chinese, Japanese or Korean text whose page embeds no font fontconfig lists as covering those languages. LibreOffice drew the words blank or as empty boxes, while pdftotext still extracts them, so overflow passes on text nobody can see. The render links the font fc-match picks for each CJK language into LibreOffice's profile whenever the deck carries such text, so this fires where that lookup failed or found no CJK font. Silent when fontconfig or pdffonts cannot run. ERROR. |
chart-negative |
the .pptx itself | A bar or column chart carrying a negative value — not a fault in the file, but one the render cannot verify, because LibreOffice plots it as positive. Line and scatter series are unaffected and are not flagged. See charts.md. |
chart-datapoints |
the .pptx itself | A bar or column chart plotting fewer than four values across all its series — the treatment choosing.md says is usually a stats row in disguise. The finding quotes that rule. WARN, never an error: the judgement is contextual, and a build that refused it would be wrong more often than the author. Only the six bar and column kinds; a line reads as a direction between ordered points, a pie's slices are the composition itself, and an XY or bubble mark already carries two or three numbers. |
series-colour |
the .pptx itself | Two series in one chart drawn in the same fill: the theme's accents ran out and the palette cycled, so a reader cannot tell the series apart. A fill given as a scheme reference is resolved through the theme first. WARN. |
alt-text |
the .pptx itself | A picture, chart or other graphic frame carrying neither alternative text nor Office's decorative flag, or whose only alternative text is an image file name such as photo.png. A table's frame is text a screen reader reaches, and is not asked. Reads the package, so alt text added by hand in PowerPoint counts. WARN. |
link |
the .pptx itself | A click action or hyperlink that cannot reach its target: a jump to a slide the show no longer contains, or declared through a relationship nothing holds (ERROR); a relative jump no show knows (first, previous, next, last, PowerPoint's last-viewed and end-show are known), or a web link that is not an http, https or mailto address with a host (WARN). Reads the package, so a link added by hand in PowerPoint is checked too. |
link-contrast |
the .pptx itself + manifest | A linked shape whose template hlink colour fails 4.5:1 on the shape's recorded ground. That colour is what Keynote draws a link in, whatever ink the build wrote; PowerPoint and LibreOffice draw the line's own ink. WARN. |
beats |
manifest only | Reports each animated slide's rhythm — how many clicks the build spends and how many shapes each beat reveals — in the vocabulary the author wrote (animate: together, animate: one_at_a_time, reveals:, a chart's own build). INFO, so it never fails a run on its own; it is the only place a deck's reveal order surfaces beside <deck>.beats.md. |
morph-unpaired |
manifest only | A morph: name on a slide that arrives by morph with no namesake on the slide before, so Morph has nothing of that name to pair it with; or a morph slide on which no placement carries morph: at all. WARN. |
beat-size |
manifest only | One beat of a staged build revealing more shapes than DECKWRIGHT_MAX_BEAT_SHAPES (default 6) — a slide that asked to be revealed a piece at a time and delivers most of it on one click. animate: together, a chart build and a reveals: trigger are exempt: one click is what they declare. WARN. |
dead-trigger |
the .pptx itself | An interactive reveal that cannot do its job: a hidden shape whose every trigger is itself hidden, so nothing can ever be clicked to show it (ERROR), or a target the slide's main build also reveals, so it is already on screen when the trigger is clicked (WARN). The compiler refuses every spec-level case, so this catches a hand-edit or a regression. |
shape-id |
the .pptx itself | Two shapes on a slide sharing an id, or an id outside 1..2147483647. Ids are counted per branch of an mc:AlternateContent: PowerPoint stores an equation or a 3D model as a Choice and a Fallback copy under one id, and no reader draws both, so that pair is not a duplicate. Two shapes inside one branch, or a branch's shape and one beside the wrapper, still are. |
theme-substituted |
manifest + the resolved theme | The recorded theme_path was not there, so the theme was resolved by name — and the file that answered hashes differently from the one the deck was built against. Every other finding is measured against that other theme's palette, grid and rungs. Pass --theme to name the right one. WARN. |
stale-manifest |
both | The deck's bytes no longer hash to what the manifest recorded — it was edited after the build, so every other finding describes the file that was built rather than the one on disk. WARN. |
shape-name |
the .pptx itself | Two shapes on a slide sharing a name, counted per branch like shape-id. Legal OOXML and invisible in a render, but it costs the deck the mapping back to its spec — see Shape names. WARN. |
animation-target |
the .pptx itself | An animation naming a shape id the slide does not contain. |
relationship |
the .pptx itself | An r:embed/r:id nothing declares, or one pointing at a part the package does not hold. A target is resolved against the slide's folder, or from the package root when it starts with /. |
package |
the .pptx itself | The file is not a readable .pptx, or a slide part is not well-formed XML. |
The first five read the manifest, not the .pptx file's actual XML — the manifest
is the compiler's own record of what it meant to place.
render-contrast reads neither. On a slide whose text sits on a photograph there
is no recorded pair to read — what is behind a title is whatever that picture happens
to be there, blended with whatever scrim went over it — so this one opens the render
and measures the colour actually surrounding each line. It is the only check that can
catch a scrim the build thought was enough and a renderer composited differently, and
it runs only on slides showing a picture — one this deck placed, or the backdrop the
template itself paints, which the manifest records on the slide rather than on any
shape.
The last four read the saved package, and they answer a different question: not "is this slide well designed" but "will PowerPoint open this file at all". PowerPoint validates the package before it draws anything, and a duplicate shape id, a dangling relationship or an animation targeting a shape that was never drawn all produce the same outcome — a repair prompt, and silently discarded content if you accept it. LibreOffice is far more forgiving, so a render that looks perfect proves nothing here. Timing is the one tree deckwright writes as raw XML, which is why its targets are checked against the shapes actually present.
A shape from a placement the author declared bleed: true is exempt from bounds:
leaving the canvas is the instruction, and reporting it as an error would mean a
bleed-heavy deck could never produce a clean run. The exemption follows the
declaration, recorded in the manifest, not the geometry — a shape that escapes
without saying so is still an error. A full-bleed shape (at the origin and at least
slide-sized) is likewise exempt from bounds and reserved by design — that is
chrome, not a placement bug — and shapes recorded
with rendered: "image" are skipped by contrast and overflow, because a
rasterized panel is not text the manifest can vouch for.
bounds and reserved are checked once per distinct shape+box — a
multi-paragraph textbox is one geometry no matter how many paragraphs it
holds, so it is not reported once per line. min-font and contrast are
checked once per manifest row — and a row is not always a paragraph.
The composer records one row per chrome paragraph it draws, so a defect on any
line of a title/subtitle stack is caught. The body components do not: each
records one row per shape naming a deliberately dominant size and colour.
callouts records the head's 18pt and title colour for a row that also
holds 13.5pt body copy; stats records the value's size and accent
against the tile fill for a row that also holds the label. What a row does
not name is not checked — that body copy is never contrast-checked, and that
label's size is never compared to the theme minimum.
table is the exception: it records one row per cell, each with that cell's
own box, so every cell is contrast- and size-checked and bounds/reserved
measure the column rather than the table. A finding names the cell —
's3.p2.table.r2c4' — not the table. A cell that reaches with across: or down:
records the box it really covers, so the check measures the span and not the
first square of it; the cells it swallowed record nothing, because they are not
there to measure.
Two skips remove rows from these checks entirely, silently: contrast skips
any record missing a foreground or background colour, and min-font skips
any record whose font_pt is null. Both are common — the 29-slide feature-tour
deck's manifest carries 129 native text rows, of which 16 have no recorded
size and 13 have no colour pair. A clean min-font/contrast run does not
mean every line was measured; it means every line that named a size and a
colour pair was.
The manifest opens with what produced it, before the records it describes:
| Key | What it is |
|---|---|
build_id |
Identity of the build: the spec, the theme and the deckwright version. The same inputs give the same id. The deck carries it too, as deckwright:<build_id> in its core properties' identifier, which is how diff finds the manifest for a copy saved under another name. |
deckwright |
The version that wrote the file, so an old manifest is recognisable as old. |
spec |
The .deck.yaml this deck was compiled from. |
spec_hash |
That file's contents when it was read. |
deck / deck_hash |
The .pptx written, and its contents. |
theme / theme_hash / theme_path |
The theme, unchanged from before. |
canvas |
{"w", "h", "unit": "in"} — the slide size, rounded like every other inch. |
Each slide records the transition it arrives on — the theme's kind, or none for a hard
cut — beside its animations. A manifest written before the key existed carries none, and
diff treats that as unknown.
Paths are written relative to the manifest wherever the two share a directory
tree, so a manifest handed over beside its deck carries no absolute home directory,
and the pair survives being moved. qa resolves theme_path back against the
manifest's own location, and falls back to the recorded theme name when that
file is not there — which is what lets a deck on theme: base be checked by someone
who has the package but not your directory layout. A name resolves against the
reader's own theme directory first, so a deck naming a brand can pick up a different
theme of that name; the recorded theme_hash is compared and a mismatch is reported
as theme-substituted.
The three paths are written relative to the manifest, so a manifest handed over
beside its deck carries no build-machine path, and a deck directory survives being
moved. qa resolves theme_path against the manifest's own location; an absolute one
— an older manifest, or a build whose theme shared no ancestor with its output — is
used as written, and a path that is no longer there falls back to the recorded name.
deck_hash is the one that catches a hand-edited deck. Every check below describes
what the build intended; open the .pptx in PowerPoint, move a box and save, and the
records here still describe the deck as built. qa compares the hash on every run and
reports stale-manifest when they differ — a warning rather than an
error, because hand-editing is the sanctioned way to finish a deck. Once it fires, read
every other finding as history.
Each slide records the placements it drew, before its shapes:
"placements": [
{"origin": "s1.hero.bullets", "component": "bullets",
"box": {"x": 0.8, "y": 2.1, "w": 5.767, "h": 4.95}}
]origin is the prefix every shape that placement drew carries in its name, which is
the only link from a shape back to the rect it was constrained to — placement-fit
walks it. component is what the placement declared, and the check reads it to exempt
connector, which draws between two other placements rather than inside its own.
A shape drawn on no placement — chrome, the background — matches no origin and is
simply not measured, so nothing has to list it. A slide that drew no placements at all
records no placements key.
plate on a shape marks a surface the compiler painted so something else could be read
on it. It is deliberately larger than the text it sits behind, so placement-fit skips
it; it is the one overhang the compiler chooses rather than the author. A plate that
records lines sets them itself, and overflow looks for them inside the plate alone.
font_pt is the shape's dominant size — the one min-font and contrast judge it by.
A shape whose paragraphs sit at different rungs also records line_pt, one size per
entry in lines:
"lines": ["Precedent is not permission", "Existing code tells you what someone did once"],
"font_pt": 18.0,
"line_pt": [18.0, 13.5]Without it a reader has no way to tell a 13.5pt body line from its 18pt heading, and
measuring both at 18 over-reports the shape's height by half. record() refuses a
line_pt whose length does not match lines, so the two cannot drift apart.
A shape that spaces its paragraphs apart also records space_after_pt, the points set
after each line's paragraph — one entry per line, refused on a length mismatch the same
way. A bullets column records 8 after every bullet:
"lines": ["• First point", "• Second point"],
"font_pt": 16.0,
"line_pt": [16.0, 16.0],
"space_after_pt": [8, 8]text-fit adds every entry but the last, which sets no ink below it. A shape without
the field is measured with no space between its paragraphs.
Every shape is named for the spec node that drew it, in the manifest and in the
.pptx itself:
s7.p2.card#1 slide 7, second placement, a card — its first shape
s7.hero.card#1 the same placement, where the author gave it `id: hero`
s7.chrome.title a chrome line with its own `at:` — its own textbox
s7.chrome the stacked chrome; its lines are paragraphs in one frame,
so the manifest holds `s7.chrome.title` and the package cannot
s7.p4.table.r2c3 a table cell, which is not a shape and so is named only here
s7.bg#1 the slide's background
#N counts shapes in the order they were drawn, not the order you would name
them. Where no colour reads behind a mark, the compiler paints a plate of the slide's
paper first and the component draws on top — so #1 is the plate and the component's
own first shape is #2. A geometry finding against #1 is about the plate, so read it
against the shape it sits behind rather than against the component you named.
PowerPoint preserves shape names through an edit, so the name is what maps a shape in a hand-edited deck back to the spec that made it — and what the Selection Pane shows while you are editing.
A placement's number moves when another is inserted above it. Giving it an
id: fixes its name instead.
A shape's rectangle is keyed and in inches from the top-left of the slide:
"box": {"x": 0.8, "y": 0.375, "w": 11.733, "h": 1.05}Inches are rounded to three decimals and point sizes to two. PowerPoint stores
geometry in EMUs — 914400 to the inch — so dividing back out leaves sixteen digits
of binary residue, and a 13pt line records as 12.99975. The rounding is an order
of magnitude inside the checks' own tolerances (0.01in at an edge, 0.02in for a
full bleed), so no check can change verdict, and a one-EMU shift no longer rewrites
fourteen digits in a diff.
A key still at its default is not written. No "text": null, no "lines": [],
no "bleed": false — about a third of the keys in a deck's manifest. A reader that
wants one falls back to the default on ShapeRecord: rendered is "native",
bleed and backdrop are false, everything else is null or empty.
Read a box with deckwright.compile.record.box_of(shape), which returns
(left, top, width, height) or None, and the slide size with canvas_of(manifest).
A manifest written before boxes were keyed raises a SpecError naming the rebuild.
A shape carrying alternative text records it as alt, and one marked decorative records
"decorative": true. Both are read back from the shape as written into the package, so
they are what alt: and decorative: produced, not what the spec asked for.
A shape drawn by a goto: placement records goto: the slide id or relative jump a click on
it takes. A shape whose text carries [words](address) links records their addresses as
links, and its text and lines are the words shown, not the markup.
A slide's animations are one entry per build, each a list of steps — one click
each, in order — naming the shapes that arrive:
{"kind": "click_sequence",
"steps": [["s3.p1.callouts#1", "s3.p1.callouts#2"],
["s3.p1.callouts#3", "s3.p1.callouts#4"]]}Names rather than shape ids: an id says nothing to a reader, and is not unique on a
slide — every cell of a table reports its frame's. A shape animated but never
recorded keeps its id as shape 4, which at least says which. The motion role a
component reported is not recorded; it had already chosen an OOXML preset before the
manifest was written.
The manifest describes a thousand shapes to say what a few hundred lines of text are —
by line it is about 3.6% content. deckwright build therefore writes
<deck>.content.md beside it: the same build rendered for a reader, slide by
slide, with the chrome as headings, tables as tables, bullets as bullets, and speaker
notes as quotes. Each block is labelled with the origin that drew it, so a line you
want to change points at the placement to change it in.
It is derived from the manifest and carries the same build_id — one writer, two
renderings, so they cannot disagree. Regenerate it; never edit it.
bounds, placement-fit, reserved, and overflow (plus the page-count
fallback overflow emits when the manifest's slide count doesn't match the render's
page count) are error, and so is every package check — shape-id, animation-target,
relationship and package — since a file PowerPoint will not open is not a
matter of degree. min-font, render-contrast, placeholder, font-substituted,
fill-ground and canvas-size are warn; contrast is warn above a 3:1 ratio and error below it,
as its row above says. dead-trigger is error for a reveal that can never fire and
warn for one that reveals what is already on screen.
beats is the only info finding, and it is emitted for every animated slide rather
than only when something is wrong — it is a report of the reveal order, not a fault.
So --fail-on info exits non-zero on any deck that animates. That is what
--fail-on info asks for; --fail-on warn is the threshold that treats findings as
faults. Findings are data, not exceptions — a
deck with findings still builds and qa still exits 0 unless you pass --fail-on:
bin/run qa "out/feature-tour/deckwright Feature Tour v15.pptx" --fail-on errorexits non-zero only if the worst finding meets or exceeds the named severity
(error / warn / info). Without --fail-on, qa always exits 0 regardless
of what it found — it is a report, not a gate, until you opt into one.
A manifest with no slides — an empty list, or the key missing entirely —
adds one more finding: empty-manifest, warn severity, slide 0. Without
it, a manifest with nothing to check would silently report zero findings, the
same as a deck that was checked and found clean; this finding is what tells
those two apart.
A manifest whose recorded canvas is not a positive width and height adds
canvas-size, warn, slide 0, for the same reason: a render's pixels can
only be mapped to slide coordinates through that canvas, so render-contrast
measures nothing at all — and a check that measured nothing must not read the
same as a check that found nothing.
qa.md opens with a one-line severity tally, then one ## Slide N section per
slide that has findings, each finding one bullet with a severity icon
(✗ error, ! warn, · info), the check name, and its detail string. Slides
with no findings are omitted entirely — an empty section would just be noise.
Findings are sorted slide-ascending, worst-severity-first within a slide.
qa.json carries the same findings as a flat list — slide, check,
severity, detail, box (the shape's declared rectangle as
{"x", "y", "w", "h"} in inches, or null), shape (the shape's name, or
null) — plus a counts object, for a script to gate on without re-parsing
markdown.
Read at call time, so a long-lived process
picks up .env changes between calls:
| Var | Default | Controls |
|---|---|---|
DECKWRIGHT_PDFTOTEXT |
pdftotext |
The Poppler binary the overflow check shells out to. |
DECKWRIGHT_PDFTOTEXT_TIMEOUT_S |
60 |
Seconds before that call is killed. |
DECKWRIGHT_SOFFICE |
soffice |
The LibreOffice binary qa uses to render (unless --no-render). |
DECKWRIGHT_PDFTOPPM |
pdftoppm |
The Poppler binary that rasterizes that render's PDF. |
DECKWRIGHT_RENDER_DPI |
110 |
Rasterization DPI for that render. |
DECKWRIGHT_PDFFONTS |
pdffonts |
The Poppler binary font-substituted and cjk-unrendered read the render's embedded fonts with. |
DECKWRIGHT_PDFFONTS_TIMEOUT_S |
60 |
Seconds before that call is killed. |
DECKWRIGHT_FC_LIST |
fc-list |
The fontconfig binary those checks ask for PostScript names and CJK coverage, and font-substituted falls back to for installed families. The render asks it for the font files of the faces the deck names. |
DECKWRIGHT_FC_MATCH |
fc-match |
The fontconfig binary the render asks for each CJK language's font, when the deck carries CJK text. |
DECKWRIGHT_FC_LIST_TIMEOUT_S |
20 |
Seconds before either fontconfig call is killed. |
Read this section before trusting a clean qa run. A reader who over-trusts
this layer is worse off than one who knows its edges — treat every check here
as a strong signal on a narrow slice of "is this deck okay," not a guarantee.
- Nothing here watches an animation play. LibreOffice draws a slide's final state, so
a build mid-reveal is invisible to the render and a transition produces byte-identical
images with and without one.
beats,beat-sizeanddead-triggerread the timing structurally — how many clicks, how many shapes per beat, whether a trigger can fire. Whether the order teaches the room anything is a judgement, and<deck>.beats.mdexists so you can read the reveal order in words and make it. Whether five consecutive slides share one rhythm is the run test applied to motion;treatments.mdcarries it as prose, because the tell is a click that stops changing what the room learns. - A trigger that emphasises rather than reveals is not judged.
dead-triggerlooks for entrance effects. An interactive trigger that animates a shape already on screen is legitimate hand-authored PowerPoint, and this layer cannot tell it from a reveal someone broke. - A shape's declared box is not its rendered text extent.
boundschecks the box the manifest recorded for a shape — it has no way to know whether the text inside that box overflows the box's own edges. This is exactly how a real bug shipped earlier in this project: a bulleted list rendered a foot off the bottom of the slide while every geometry check reported clean, because the shape's declared frame was on-slide even though its rendered text was not. If a component can grow taller than its declared box (long bullet lists, unbounded user content), that risk lives entirely outside whatboundscan see. - A shape can still overlap a neighbour without leaving its own rect.
placement-fitcloses the case where a component draws past the rectangle it was handed. It does not close the case where the box is legal and the ink is not. Abox:placement is exact geometry and is never narrowed, so two boxes the author wrote may legitimately overlap, and aplateis exempt by declaration. Rotation and effects are absent from the manifest by construction —Box.from_emureads the shape's transform and nothing else — so shadows, a centred outline straddling its own edge,fanout's rotated arrowhead and PowerPoint's own table auto-grow are rendered ink that no box check can see. The render remains the only thing that catches those, by eye. - Text inside a rasterized panel is invisible to
pdftotext. Anything recorded asrendered: "image"(HTML-card screenshots, doc/code panels) is skipped bycontrastandoverflowon purpose — checking it would report false losses for text a PDF extractor structurally cannot read. That also means those panels get zero automated coverage: overflow, illegible type, and low contrast inside a screenshotted card are only caught by eye. Seedocs/panels.mdfor the full panel pipeline and what choosing a panel over a native shape costs. placeholderreads the manifest's words, so a panel hides its copy too. Scaffold orTODOtext inside an HTML card or a doc/code panel is a picture by the time the deck is written, and never reaches the manifest as text — the same blind spotcontrastandoverflowhave there. Nor does the check read intent: it matches literal phrases, so a real sentence that happens to contain one is reported, and copy that is placeholder in every sense but its wording is not.placeholdernames four scaffold phrases. Only copy no ordinary deck would write is on the list:A KICKER,Three lines and a chrome block make a cover,Three things are brokenandquestions@example.com. An unedited scaffold therefore draws four findings, on three of its six slides, and its other seeded lines — Three moves, The first thing, What we chose., Adoption climbs every quarter — pass, as the same words would in a deck someone wrote.- The x-run is matched in either case, and asks nothing about what it stands for.
A masked identifier (Card ending XXXX 4242), a Roman numeral (Section XXX) and a
front-matter page reference all report alongside a real
xxxplaceholder. Three or morexstanding as a word of their own is the whole test — a run inside a word or between digits (maxxxx, 1xxx2) is not one. text-fitsees only what a component records. A shape whose paragraphs sit at different rungs records aline_pt— one size per line — and each is measured at its own. A multi-line record without those sizes is skipped rather than guessed at, because measuring a body paragraph at its heading's size over-reports by a wide margin and would fill a sound deck with invented findings. So a component that records a heading and its copy under onefont_ptis invisible to this check until it records the sizes too. Space between paragraphs is counted only where a component recordsspace_after_pt;bulletsdoes, and a shape without it is measured as though its paragraphs touched.- Every width this layer measures is only as good as the face's metrics.
text-fit, and the height arithmetic every component uses to size its own boxes, are computed from per-character advances — and deckwright ships those for the nine familiestheme.mdlists, plus CJK measured on its em square. A theme naming anything else is laid out against a deliberately loose ceiling, so a cleantext-fiton such a deck means "nothing overflowed the widest estimate available," not "the text fits." The build says so at the time:warning theme_face_unmeasurednames the face and the role. Read that warning before trusting this section. It compounds when the face is also not installed where the deck is opened — the build reserves ceiling width, the renderer substitutes something narrower, and a slide that overlaps for your audience reports clean for you. Seetheme.mdfor the table of measured families and what to do about it. font-substitutedanswers for this render, not for the deck. It reads the fonts the rendered PDF embeds, so it catches a renderer that cannot reach a facefc-listsays is installed. The render links each such face into LibreOffice's profile before converting, so what it mostly reports is a face missing from the machine that rendered. It matches a face to an embedded font by name and by the PostScript names fontconfig reports, so without fontconfig — macOS does not ship it;brew install fontconfigsupplies it — a face whose PostScript name differs from its family name (游ゴシックisYuGothic) is reported substituted when it was not. Withoutpdffontsit falls back to askingfc-listwhich families are installed, and without either it says nothing, and--no-rendermakes it say nothing by design. What it does answer for is the box that rendered, which is the one whose pixelsoverflowandrender-contrastread.cjk-unrenderedtrusts fontconfig's language lists. A page passes when it embeds any font fontconfig says covers Chinese, Japanese or Korean, so a CJK font missing only the glyphs a slide uses still passes, and a font fontconfig cannot see fails. The laptop the deck is finally opened on is unobservable from here: a face present on your machine and missing on the presenter's substitutes there, and nothing in this layer sees it.alt-textchecks that words are there, not what they say. Alt text reading "chart" passes. Only pictures and graphic frames are asked: a glyphiconis a drawn shape, and a screen reader in PowerPoint announces an unlabelled one without this check reporting it. Office's decorative flag is written from [MS-ODRAWXML]'s schema; the extension URI it sits under is not in that document, but PowerPoint for Mac 16 reads the flag — Mark as decorative shows ticked — and writes the same extension back,{C183D7F6-B498-43B3-948B-1728B52AA6E4}aroundadec:decorative val="1".min-fontandcontrastsee rows, not lines. A manifest row can stand for a whole multi-paragraph shape under one dominant size and colour, and a row that names no size (or no colour pair) is skipped without a finding — see "The checks" above for which components record which. Text can therefore be too small, or below AA, on a deck these two report clean.contrasttrusts the manifest's recorded colours, not a sampled pixel. If a component records the wrong foreground or background role — e.g. the page background role instead of the dark image actually behind a shape — the check computes a ratio for colours that were never really adjacent on the slide. It is exactly as accurate as the compiler's own bookkeeping.render-contrastis the answer to that on a slide carrying a picture, and only there — everywhere else the manifest's colours are all anything reads, and--no-renderremoves even that.- The render is LibreOffice, not PowerPoint. Font substitution and text
metrics differ between the two, so
overflow's judgment of what did or didn't survive rendering is a strong signal, not a guarantee — a line that fits in LibreOffice's font substitute could still clip in real PowerPoint, and vice versa. Animation states are also irrelevant here: LibreOffice renders the final built state, so aqarender cannot see a click-reveal that never gets to that state. A slide transition is invisible to this layer twice over: it produces identical renders, and it touches nothing in the manifest, so no check reads it at all. Schema validity and child order for both are covered bytests/test_ooxml_schema.py, not byqa— seemotion.md. It cuts the other way too: LibreOffice's importer has its own faults, so a render finding can mean the deck is fine and the renderer is not. Atablerow that is entirely a vertical merge is legal OOXML which LibreOffice mis-imports, dropping the table's last row and moving the whole table —inspectand the manifest both read correct. Isolate before believing either side: build the shape with one variable changed and render both. - A structural readback is not a render.
tests/test_templates.pybuilds every exercise against every real template and reads the result back out of the file, which is a strong check on what the compiler wrote and no check at all on what a renderer does with it. The vertical-merge fault above passed the corpus against all eleven templates and was caught only by rendering a built deck. A capability is not proven until something has looked at it. overflowis a normalized substring search, not a layout check. It proves a line of text landed somewhere on the rendered page — not that it landed inside its own shape, not that it isn't overlapping another shape, and not that it wasn't shrunk to the edge of legibility to fit. Matching ignores case, spacing, and hyphens —pdftotextrebuilds a line the renderer wrapped at a hyphen as eithernontext(reading order deletes the hyphen) ornon- text(-layoutkeeps it at the row break), and neither is loss — but that same leniency means a garbled or reordered rendering of the right characters would not be flagged either.- A line its own box cannot be read for still reports a false
overflow. Both whole-page extractions read a page row by row, so where two columns sit side by side the neighbouring column's text lands between the halves of a line the renderer wrapped. The check answers that by asking again inside the shape's own box, which resolves the ordinary two-column case — but only whenqarenders, and apdftotext -bboxpass that errors is logged and treated as a miss, so the finding comes back. On a single[error] overflowit is still worth a look at the render. - None of these checks are a design review. Visual hierarchy, alignment, spacing balance, and "does this look intentional" are out of scope entirely — closing that gap is separate work, not this one.
A manifest-only check is a function (manifest: dict, theme: Theme) -> list[Finding]
in src/deckwright/qa/geometry.py. That dict is the serialised form of ShapeRecord,
PlacementRecord and SlideRecord in src/deckwright/compile/record.py — read those
dataclasses for the keys and their units rather than inferring them from a built deck's
JSON. manifest.py only writes them. One that
needs the render goes in
src/deckwright/qa/textflow.py (extracted text) or src/deckwright/qa/imagery.py
(pixels, taking the rendered images rather than the theme); one that reads the saved
package goes in src/deckwright/qa/package.py, taking the deck path alone — unless it asks
something other than "will PowerPoint open this file": what the chart parts say about the
charts is src/deckwright/qa/charts.py, what a screen reader is told about each figure is
src/deckwright/qa/alt.py, and what the timing says is
src/deckwright/qa/motion.py, which reads the manifest for the rhythm and the package for a
reveal that cannot fire. Wire it into
run_qa (src/deckwright/qa/runner.py) — inside the if render: block if it needs one —
and give every Finding a severity that matches the table above: error for a
placement/content defect the audience will notice, warn for a quality issue worth a
human decision. Add its row to "The checks" table and, if it has a blind spot, a
bullet in "What this layer cannot catch."