Plank help · updated 2026-08-27
How Plank builds a slide deck
A slide deck comes out in one of two real formats — an editable .pptx, or an art-directed HTML → PDF that cannot be edited — and the format is asked, never assumed: read the format question before you start building. Both share one house style with two themes (art-directed dark by default, light typographic on request), validated palettes, thirteen slide primitives including full-bleed imagery, persistent brand chrome, plus a tool that renders the finished deck, reports what is broken (including text that is illegible over its own photograph), exports the PDF, and runs a critique pass on whether the deck is any good.
Agents: fetch the raw markdown of this page at /en/help/presentations.md
How Plank builds a slide deck
Just need to build one? The whole route — download the builders into
/workspace/scripts/deck/, write the build script beside them, run the QA before handing over — is one short page: Build a deck: the route. This page is the style behind it, and it is long; read it for the primitives, the palettes and the format argument.
When Plank builds you a slide deck it comes out as a real file, in one of two formats, and which one is asked, not assumed:
.pptx— editable text, shapes and native charts you can open in PowerPoint, Keynote or Google Slides and change by hand. Never a picture of slides, never a PDF pretending to be one.- HTML → PDF — one self-contained HTML document printed to PDF at exactly one page per slide. Full-bleed photography, layered gradient scrims, real letter-spacing: the art-directed look a shape-based format cannot draw. You cannot edit it.
Both are built from the same primitives, the same two themes and the same text budgets, so the choice is about what the deck has to do — not about learning a second tool. See Which format.
This page is written for both you and the assistant. The assistant fetches it at https://plank.md/help/presentations.md before building a deck, so every deck comes out in the same house style.
First: ask, then build
This is the first step, not an optional one. Before writing a single slide, the assistant asks a short batch of questions and waits for the answers. A deck is a piece of persuasion aimed at particular people in a particular room, and the four or five facts that decide its shape are almost never in the original request. Guessing them produces the failure this section exists to prevent: a competent, well-formed twelve-slide deck aimed at nobody, which the reader has to be talked through because it was never built for them.
Read this before you choose the questions: the format is not yours to assume
Decide nothing about the output format before you ask. There are two, and picking the wrong one is expensive in a way no amount of good slides repairs: an HTML → PDF deck cannot be edited, and a .pptx cannot be art-directed. The three lines that decide it:
- It has to land — a pitch, a brand deck, a launch, a conference talk →
plank_slides.py→ HTML → PDF. - It has to be edited — re-cut, re-branded, reused as a template, opened in PowerPoint →
plank_deck.py→.pptx. - A board pack, a financial review, a monthly report → ask; usually
.pptx, because these get re-cut.
A request like "make a fancy pitch deck" does not answer this: it names a genre, and a genre only points. Reading "fancy" as permission to start building is how the user who wanted a deck that lands gets a shape-based one. So the format is a question you ask — it is in the table below — unless the request has already answered it in words ("something I can edit", "just a PDF", "a deck the board can re-cut"), in which case say which you picked and why. Full routing and what each format gives up: Which format.
HTML → PDF also depends on something the request can't answer: a renderer in this workspace. Before you commit to that route, check command -v chrome-headless-shell >/dev/null 2>&1 || [ -x /opt/chrome-headless-shell/chrome-headless-shell ] || [ -n "$PLANK_CHROME" ]. Empty means this workspace cannot print HTML — say so plainly and build .pptx instead: you lose the full-bleed look, but the deck still ships. See Which format.
How the assistant asks
It uses the question tool, which is built in and always available — the same mechanism behind the multiple-choice card you see in a chat. It takes a list of questions, each with a short header, the question itself, and a set of options:
question(questions=[
{
"header": "Format",
"question": "Does the deck need to be editable, or does it need to look art-directed?",
"options": [
{"label": "Editable", "description": ".pptx — opens in PowerPoint, changed by hand"},
{"label": "Art-directed", "description": "HTML → PDF — full-bleed imagery, cannot be edited"},
{"label": "You choose", "description": "Pick from what the deck is for"}
]
},
{
"header": "Audience",
"question": "Who is in the room, and what do they already know?",
"options": [
{"label": "The board", "description": "Knows the business, not this project"},
{"label": "The finance team", "description": "Knows the numbers in detail"},
{"label": "A prospective customer", "description": "Knows nothing about us"}
]
},
...
])
You answer by tapping an option — or by typing your own, since a "Type your own answer" row is added automatically. Set "multiple": true on a question that can take more than one answer. Ask them all in one call. One question call carrying four questions is one card you answer once; four calls across four turns is an interrogation, and it is the single most common way this goes wrong.
What to ask
Format is always asked, and it is outside the count — it is the one answer that picks which builder runs, so skip it only when the request has already answered it in words. Then pick three to five of the rest, the ones the request has not already answered. Never the whole table.
| Ask | Why it changes the deck |
|---|---|
| Format (always, unless already answered) | Editable, or art-directed? .pptx opens in PowerPoint and can be re-cut by hand; HTML → PDF gets full-bleed photography and a real type engine and cannot be edited. This is the answer that picks the builder, which is why it does not compete with the three-to-five. See Which format. |
| Audience | Who is in the room, and what do they already know? Decides how much you explain and what you can assume. |
| The decision | What should they do or decide when it ends? A deck without one is a report, and a report does not need slides. |
| Genre | A business report or a pitch? Picks the theme (report over the default studio), and it is the strongest hint at the format — but a hint is not an answer, so it does not replace the format question. |
| Length | Roughly how many slides, or how long is the slot? Ten minutes is not twelve slides. |
| Brand or template | Is there a company template, brand colours or a logo to match? See Your own brand. |
| Imagery | Real photos to use, a generated image, or none at all — typographic is often the better call for a financial or reporting deck. Real photos are files already in the workspace; either way this decides whether hero and image_content are in play. Offer generation only when it is actually available for this workspace — check with [ -n "$PLANK_IMAGE_URL" ] — and if it isn't, say so plainly and offer the other two options instead of promising something that will fail; don't nag the user to install a skill they didn't ask about. Generated images cost a few cents each against the workspace's AI allowance, unlike the rest of the deck. |
The rules that keep this short
- Once, up front, in one batch. Not one question per turn.
- Never ask what you have already been told. "A pitch for the board on why we should buy X, ten minutes" answers audience, decision, genre and length. Ask about format, brand and imagery. For format specifically, only words about editing or finish answer it — "something I can edit", "just a PDF", "a deck the board can re-cut". "A fancy pitch deck" does not.
- Cap the rest at five. Three is usually better. Format sits outside that count, not inside it.
- Do not stall. If the user says "just make it", answers only some, or does not answer at all, build the deck using stated assumptions. Say what you assumed in one line, and name the format among the assumptions — "Built for a board audience, asking for pilot sign-off, twelve slides,
studio, HTML → PDF because it has to land — say the word and I'll rebuild it as an editable.pptx" — so a wrong assumption is cheap to correct. A deck that exists is correctable; a question sitting unanswered is not a deliverable.
Then: the storyline, before anything is built
With the answers in hand, propose the deck as a list of titles and stop there. Nothing is rendered yet. The storyline is the argument; the slides are only its packaging, and this is the last cheap moment to be wrong about it — a full build is minutes of tool calls, and one with generated imagery spends real money against the workspace's AI allowance. Rewriting a line in a list costs nothing.
It is six to fifteen lines in your reply, each one the title a slide will carry, in order, each stating what that slide claims. The first line is the answer — see Writing the slides.
1. Q3 support cost fell 40% — triage is why
2. Volume rose 18% while cost per ticket fell 49%
3. Two of the three cost drivers are now automated
4. The third needs two analysts
5. Approve two hires by 15 September
Two rules keep this from becoming a second interrogation. It is a list, not another question round — no question call, no options; "here's the storyline, tell me what's wrong or say go". And the no-stall rule applies unchanged: no answer, no corrections, or "just make it" means build the storyline you proposed and say that is what you did.
Designing a deck from scratch
This is the method for a deck whose design is yours — a pitch, a launch, an investor update, a board pack. (A deck the customer already has is the next section; there the design is the asset and none of this applies.)
Do the three parts in order and write each one down before you draw anything. Skipping straight to slides is how a deck comes back well-formed and forgettable: every rule below exists because a deck that skipped it looked machine-made.
Part one: think
-
Frame. Who is in the room, what they already believe, the single decision this deck must get, and what they lose by saying yes. Read alone or presented live? Density follows from that answer.
-
Skeleton. Take the canonical arc for this genre — for a seed round: purpose, problem, why now, product, how it works, market, model, traction, competition, team, ask. Adapt it and say what you dropped and why. Do not invent an arc.
-
Governing thought. One sentence the whole deck supports. Anything that does not support it is cut, however good it is.
-
Claim chain. Per slide: the claim — a finding, not a subject — and the evidence that settles it, marked
have/missing/assumed. Check it is MECE: no two slides making the same point, and the set sufficient on its own. -
Close the gaps. Every
missingline is resolved one of three ways: research it withwebsearch/webfetchand keep the source, ask the user, or carry it as an assumption that is printed on the slide. An unsourced number never renders silently, and your own method —N×P×12, "cite the count" — is never content.If the brief already supplies the evidence for every claim, skip this step entirely and say in one line that you did. Research only what the brief leaves open. This is not a shortcut, it is correctness: a founder's own price list is not a claim a search engine can settle. Measured on the same subject, a brief that carried its own numbers took the whole deck from 825 s to 379 s with no loss of quality.
-
Pacing. Where does tension build and where does it release? Those beats get a visual break in part two.
Part two: design the system once
Write the design system down once, as CSS custom properties, before the first slide. Then obey it. This is what makes twelve slides look like one deck without a template doing it for you.
- Identity. One line: what this deck should feel like, derived from this subject. If a choice would work equally well on any other deck, it is the wrong choice.
- Palette. Five or six named values: ground, ink, one accent, two neutrals,
and semantic good/bad if numbers carry direction.
- Roughly 60 / 30 / 10 — one ground dominates, one secondary carries structure, the accent is a tenth or less. The accent means look here; the moment it is also on every heading and border it means nothing.
- State the measured contrast of body text on ground. 4.5:1 minimum, checked for every pair you actually use, including text over imagery.
- Never pure
#000. White on pure black halates — it is why Material and the Apple HIG both use a dark grey. A near-black with a hue bias reads chosen; a pure grey reads unconsidered. - A saturated accent calibrated on a light ground burns on a dark one. Re-pick it per ground rather than reusing the hex.
- Never encode meaning by hue alone. Carry it in the label, the sign or the position too, so it survives colourblindness and a bad projector.
- Type. Two or three faces with explicit roles: display, body, and one for
figures and labels.
-
fc-list : familyreports about 228 families, but almost all of them are exotic-script Noto. The usable Latin/Cyrillic set is Inter, Noto Sans / Noto Sans Display, Noto Serif / Noto Serif Display, Fira Code and the metric clones. Inter alone will not give a deck a voice. -
So fetch a display face and inline it. Never link a CDN — the file has to stand alone. Ask Google Fonts with a browser User-Agent and you get woff2 (~12 KB) instead of TTF (~64 KB):
UA='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' curl -sS -A "$UA" 'https://fonts.googleapis.com/css2?family=<Family>:wght@400;700&display=swap' # then curl each fonts.gstatic.com .woff2 out of that CSS and base64 it into a @font-face srcCheck the face covers Cyrillic and Kazakh (ә і ң ғ ү ұ қ ө һ) before committing to it. If it does not, keep it for display and set body in one that does.
-
Pair by contrast of structure, not similarity. A serif or a distinctive display against a neutral sans works; two neutral sans faces just look like a mistake.
-
One scale ratio (1.25 or 1.333), stated as variables, obeyed everywhere. A 1920×1080 slide over a 13.33×7.5 in surface is 144 DPI, so 1 pt = 2 px — which is what makes "never below 18 pt" usable here:
Role Size on a 1080-tall slide supporting text 36 px floor (18 pt — below this it belongs in a handout) body 40–48 px slide title 72–88 px section or statement 110–160 px one hero number 200 px+ -
Tracking: tight on large display (−0.02 to −0.04 em), normal at body size, loose on small caps and eyebrows (+0.08 em). All-caps below 24 px without extra tracking is unreadable.
-
Running text stays around 45–75 characters a line. Weight carries hierarchy more cheaply than size; three weights is enough.
-
- Grid and space. Twelve columns, one margin, one spacing scale, all stated as variables. Whitespace is a material, not what is left over — empty space is what makes the one important thing look important. On full-bleed slides put the subject and the headline on the third-lines rather than dead centre. Every slide has exactly one focal point; if two things compete, one is not on this slide.
- Layout vocabulary. Choose six to eight slide forms up front and assign one to each claim by what the claim needs — a metric trio, a comparison matrix, a stepped process, a full-bleed statement, a table, a two-column contrast. Never the same form twice in a row. Four rules hold the set together: contrast (the important thing looks different, not merely bigger), repetition (the same element means the same thing on every slide, in a fixed position), alignment (every edge lines up with the grid or with another edge), proximity (the gap between groups is bigger than the gap within one).
- Numbers and charts. Inline SVG; no libraries.
-
The message picks the form, not the data. Write the sentence you want the reader to conclude, name the comparison, then draw it:
The claim is about… Draw part of a whole one pie only at 2–3 slices, otherwise a stacked bar ranking of items horizontal bar, sorted by value, never alphabetically change over time line for many points, column for few spread of a range histogram or dot plot relation of two scatter -
Label directly. Put the series name at the end of its own line and delete the legend; put the value at the end of its own bar and delete the axis. A legend makes the eye travel between the key and the mark, and the cost of that trip is the most-replicated finding in slide research.
-
Erase anything that is not data: no 3-D, no drop shadows, no heavy gridlines, no background fill, no redundant axis. If removing a mark loses no information, remove it.
-
Emphasise one mark — the bar the claim is about — in the accent, and leave every other mark neutral. That is what makes a chart argue instead of report.
-
Many series: small multiples on one shared scale beat one crowded chart. Bars start at zero; truncating an axis to exaggerate a change is a lie.
-
- Imagery. This one cuts both ways and the deck is lost at either extreme.
- A pitch, a launch, a brand deck needs pictures. The image carries the thing the words cannot, it is what a room remembers, and a deck of nothing but type and diagrams reads as a report someone put on a projector. Plan the imagery in this step, alongside the palette — not as decoration added at the end.
- On those three genres the cover and the section breaks carry an image by default, and going without one is the choice you have to justify — the other way round from every other slide. This is written down because the honest reading of the rule below is otherwise "skip it": asked for a pitch with generation switched on, a run reasoned "original transaction-flow graphics rather than decorative stock imagery" and shipped fifteen slides of pure type. That is the right instinct about DECORATION and the wrong conclusion about a COVER. A generated image is not stock: you write the prompt, so it can be the specific thing this deck is about — this company's warehouse, this city at the hour the work happens — which is exactly what a stock library cannot sell you.
- A picture that carries no argument costs comprehension, measurably: a decorative photo, a background texture, an icon beside every bullet all compete for the same attention. The test is one question per image — what does this let the reader understand that the words alone would not? No answer, no image.
- When an image is the argument, let it be full-bleed and large, with a scrim heavy enough that text over it still measures 4.5:1 against the rendered pixels, not against your intention.
- A board pack, a financial review or anything printed is the exception: there, type and charts alone are correct.
- Check that you can actually get a picture before the plan depends on one.
Real photos are files already in the workspace; generation is opt-in per
workspace, so test it with
[ -n "$PLANK_IMAGE_URL" ]at this step, not after you have designed around it. If neither is available, say so in the reply and design a typographic deck on purpose - an original inline SVG built from the subject is a real answer, a stock-looking placeholder never is. - When it IS available, the
image-generationskill is how you use it - read that skill rather than improvising the HTTP call. For a deck the useful settings are"size": "1536x1024"(landscape, the right shape for a slide) and"quality": "low"for anything sitting behind text, which is dramatically cheaper and indistinguishable under a scrim. Generate once, at plan time, for the two or three slides that carry an argument a picture can make - a cover, a section break, the one concept with no chart. A retry loop is the most expensive mistake available here, and every image is billed to the customer. - Then inline it. The skill writes a file into
assets/, and a self-contained deck cannot reference a file: ship the image as adata:URI in the HTML. A deck that points atassets/cover.pnglooks perfect on the machine that built it, prints correctly to PDF, and arrives as a broken frame the moment the.htmlis shared on its own.plank_deck_qa.pyflags that, because it is invisible until somebody else opens the file.
- Texture and gradients.
linear-gradientandradial-gradientare exactly what a gradient in a PDF is, so they translate straight through and cost nothing. Use them freely.- Never
repeating-linear-gradient,repeating-radial-gradientorconic-gradientin a deck that will be printed. Chromium cannot express those three, so it falls back to a shading whose colour a reader has to compute once per pixel — and Preview on macOS and iOS rasterises the whole element box before it clips, so the price is set by the size of the box the gradient sits on, not by how much of it is visible. - Measured on a real twelve-slide deck: 43 seconds to open, 26 of them on the title slide, and 27 of the 43 spent on one hairline grid that drew zero pixels — it was clipped away to nothing and still cost more than half the deck. The page stays blank until it finishes, so it reads as a corrupt file rather than a slow one, which is how it got reported.
- For a grid, a scanline, or any repeating texture, use a repeating SVG
background-image— adata:URI holding one tile. Same look, still vector, and measured at 0.02s against 0.49s for the gradient version of the same overlay. For a sweep,linear-gradientat an angle. plank_deck_qa.pyfails a deck on this twice: once from the CSS, and again from the rendered PDF, so it also catches one arriving inside an embedded SVG.
- Never
- Rhythm. Invert the ground on the beats you found in step 6. A deck that is one tone throughout has no emphasis anywhere.
Part three: build, then look
-
A deck is a fixed canvas that SCALES. It must never reflow. This is the one rule that separates a deck from every other HTML thing you build, and getting it wrong is not subtle: measured on a real generated deck at 1280, 1440 and 1920 px wide, the type stayed the same physical size while the canvas grew, so the proportions changed at every width — dead space under the text at 1920, and at 1280 the footer note ran straight through the slide counter. Fluid layout is correct for a document and wrong here, because a slide is a composition: you placed things relative to each other, and only a uniform scale preserves that.
Lay out ONE fixed stage and scale it to the viewport. Everything inside is in absolute px against that stage and never in
%,vw,vhorclamp():<style> :root { --slide-w: 1920px; --slide-h: 1080px; --k: 1 } html, body { margin: 0; height: 100%; overflow: hidden } #stage { position: absolute; top: 50%; left: 50%; width: var(--slide-w); height: var(--slide-h); transform: translate(-50%, -50%) scale(var(--k)); transform-origin: center center } @media print { html, body { overflow: visible } #stage { position: static; transform: none } } </style> <script> const fit = () => document.documentElement.style.setProperty('--k', Math.min(innerWidth / 1920, innerHeight / 1080)); addEventListener('resize', fit); fit(); </script>The print block resets the scale, so the PDF still comes out at full size — which is why this defect survives a PDF review and only shows up in a browser.
The transform has to PLACE the stage, not only scale it.
transform: scale(var(--k))on a box centred byplace-items: center(ormargin: auto) is the wrong shape, and it is the one that gets written. A transform never changes layout, so the stage's box stays 1920×1080; in any narrower window it overflows itsoverflow: hiddenparent, and the alignment does not move it — the browser centres it by setting the parent's initial scroll offset, once, at first layout, and nothing ever recomputes it. Measured on a real generated deck, same document, at 1040×975: correct in a top-level tab, 440 px right and clipped inside an iframe, and 180 px off in the tab as soon as the window was resized. The first number is the one that matters, because a deck is READ in an iframe — that is what the Plank file viewer is.translate(-50%, -50%)is a percentage of the ELEMENT, so it recentres at every scale with no scroll offset in the story. That is why it is in the snippet above, andplank_deck_qa.pynow fails a deck without it (deck-stage-does-not-recentre). The other correct answer iszoom, which shrinks the layout box as well as the pixels — it is what the HTML slide builder uses, and it needs no translate.And nothing in a deck may call
history.replaceState,history.pushState,localStorageorsessionStorage. That same iframe is sandboxed with no same-origin privilege, so each of those throws aSecurityErrorinstead of returning and takes the rest of the handler with it — the arrow key that was going to advance the slide simply stops working. It works perfectly in the browser you built it in, which is why it ships. Wrap the call in try/catch, or keep the state in a variable (deck-uses-origin-locked-api). -
Build it. One
<section>per slide at 1920×1080, self-contained — fonts base64 inline, images as data URIs, nothing fetched from the network. Give every slide a stable id (id="s07") and keep the tokens in one:rootblock and the fonts in one<style>: that is what makes a later "change just this slide" edit a three-line diff instead of a rewrite. Arrow-key navigation and a slide counter, plus an@pagerule that puts exactly one slide on one landscape page. -
Print it and look at it. Print to PDF with headless Chromium, rasterise every page, and call
plank_look_aton the images. Per slide: does one thing dominate, is any text cut off or overflowing, is anything unreadable against what is behind it, is the accent still scarce, do slides of the same kind match, does the deck read as one designed object. Fix, re-render, look again.Run
plank_deck_qa.pyon the HTML as well. On a deck you wrote by hand it cannot measure geometry — there is no manifest — so it checks what is a property of the source (that the file stands alone, that it will print one slide per page, that no gradient will make the PDF crawl) and then tells you, in the report, everything it did not look at. A clean report there is not a checked deck. The looking is the check.Then time the PDF, because looking cannot see this one. A page that takes twenty seconds to appear rasterises identically to one that takes ten milliseconds, so every check above passes a deck nobody can open. Count the shadings a reader has to run a program for:
python3 -c "import re,sys;print(len(re.findall(rb'/ShadingType\s+1\b',open(sys.argv[1],'rb').read())))" deck.pdfAnything above
0is a defect — see step 14 for what causes it and what to use instead. -
Say what you chose. In the reply that delivers it: the palette, the faces and the layouts, and why each came from this subject — then offer the exports (PDF, editable
.pptx, Google Slides, or a shareable link).
Working from a deck the customer already has
"Take this deck and make one like it for X" is a different job from building one, and everything above assumes you are the author. Three rules, and they are not optional.
Look at the source before you change it. Convert it and open the PNGs — plank_deck_qa.py will do it — the same way you would look at your own deck before handing it over. You cannot decide which slides are wrong, or which are fine, by reading XML. A run that edited three slides on a deck it had never looked at replaced the wrong three.
Never map supplied assets to slides by filename order or ordinal position. Given three photos and six people, "three images, three directors, slides 8-9-10" is a guess wearing the costume of a rule. Identify each asset — you can see attached images, so look at them, and compare against what the slides already carry — and place it by what it is. If two are genuinely ambiguous, ask; one question call with the names costs a turn and a wrong face on a named person costs the deal. Then say which slide each one went to, so a wrong guess is visible rather than buried.
When the deck is the customer's own credentials or press deck, its look IS the asset — keep it. A production house, an agency or a studio pitching itself has one deck that carries its identity: palette, page size, type, and the photographs of its own people. Porting it to a new client means new content — brief, team, numbers, timeline — inside that unchanged identity. The new client's brand belongs on the slides as content: their name, their logo where the source puts a client logo, their project in the metadata row. It does not become the theme.
So when you ask the branding question for a deck like this, the recommended option is to keep the source's visual language, not to restyle it into the client's colours. Restyling is the right answer only when the customer says so, or when the deck is theirs to be branded — an internal report, a template they own, a deck going out under the client's name. Getting this backwards produces a deck that looks considered and has erased the thing it was selling: a studio's pitch arriving in the prospect's corporate colours, with the studio's own faces dropped.
Two things follow that are easy to miss. Keep the page size — a source at 10×5.62in that comes back at 13.33×7.5in is a different document, whatever it looks like. And keep the imagery you were given: dropping the portraits out of a talent roster removes the roster.
Open the source. Do not re-derive it.
This is the mechanic that decides whether the three rules above are even achievable, and it is the one that keeps getting skipped.
Start from the customer's file and edit it — prs = Presentation("their-deck.pptx") — then change what the new client needs: retype the runs, swap the pictures you were asked to swap, drop the slides that do not apply. Do not start from Presentation() and rebuild their look from constants you read off their deck.
The difference is not stylistic. A run that rebuilt from empty re-typed the page size and the accent correctly, guessed the typeface wrong (shipped Carlito into an Arial deck), and took the picture count from 38 to 6 — because a constant can be retyped and a photograph cannot. The studio's logo lockup came back as its name typed into a text box. Everything you do not think to re-type is silently gone, and it is gone in exactly the places a customer looks first.
Opening the file gives you all of it for free: masters, theme, fonts, page size, every image, the logo, the chrome. Then the only thing that changes is what should change — the content.
plank_deck.py is for decks you author. It draws the Plank house style onto blank slides; it has no way to carry someone else's design across, and reaching for it on a port is how a deck comes back in our colours instead of theirs. When the customer's look is the asset, the tool is python-pptx on their file.
Use plank_deck_edit.py. Editing was awkward and building was easy, which is most of why building kept winning: python-pptx has no API for deleting a slide, duplicating one, or retargeting text without flattening its formatting. The toolkit is those missing pieces and nothing else — it never draws anything.
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck_edit.py https://plank.md/help/assets/plank_deck_edit.py
from plank_deck_edit import *
prs = open_deck("their-press-deck.pptx")
print(inventory(prs)) # look before you touch
keep_slides(prs, [0, 1, 2, 5, 9, 12, 15]) # a press deck carries extra
replace_text(prs, {"орбитекс форте": "halyk bank"})
swap_picture(prs.slides[3], "portraits/viktor.jpg")
save(prs, "halyk-proposal.pptx")
inventory prints every slide and shape with its index, position and text, so the plan is made against what is in the file rather than against a guess — and so your reply can say which slide each change landed on. replace_text edits runs, which is what preserves the design: in a well-made deck the label and its value are already separate runs, so replacing "орбитекс форте" leaves the grey "brand " grey without anyone having to know that was the rule. A string that matches nothing raises, because silently changing nothing is how a port ships the previous client's name. swap_picture replaces in place, keeping position, size and z-order — add_picture plus a delete puts the new photograph on top of the text it was meant to sit behind. And clear_links drops hyperlinks while keeping the text: replacing a link's words leaves the link, so a rewritten bullet keeps its blue underline and still points at the previous client's showreel.
Ported this way, the reference deck's slide 2 came back with the same ten shapes at the same coordinates in the same colours, sizes and italics as the source. Only the words changed.
If you would rather not take the dependency, these are the two mechanics it is built on:
# Delete a slide you do not need (most ports subtract - a press deck carries
# more than one brief needs). Removes it from the presentation's slide list.
xml_slides = prs.slides._sldIdLst
for sld in list(xml_slides)[i:j]:
xml_slides.remove(sld)
# Replace a picture in place, keeping its position, size and z-order.
old = slide.shapes[k] # the picture to swap
new = slide.shapes.add_picture(path, old.left, old.top, old.width, old.height)
old._element.addnext(new._element)
old._element.getparent().remove(old._element)
A slide the source does not have: duplicate one, then retarget it. This is the answer to "add two slides in our template", and it is the only one that works — the design is copied rather than described, so the typeface, the accent, the chrome, the page number and the background photograph all come across without anyone having to notice they exist. Pick the existing slide whose shape is closest to what the new one needs, duplicate_slide() it, move it into place, and rewrite its text with replace_text() / set_text(). Two runs doing exactly this returned all 16 original slides byte-identical and two new ones indistinguishable from the deck. What does not work is drawing a fresh slide in the house style and hoping it matches: nothing about "dark deck, lime accent" tells you the display type is italic.
The completeness check is the one that fails silently, so do it by counting. replace_text() is case-sensitive and matches the paragraph as it reads, so {"ВИКТОР КИМ": "…"} renames the profile heading and leaves Виктор Ким standing in the candidates list two slides earlier. Pass every casing the deck actually uses, then check the returned hit count against the number of occurrences you found when you looked — and grep the result for the old string before you hand it over. A run that skipped this replaced one of two occurrences and reported "no old occurrences remain", which is worse than missing it, because the report is the thing a reviewer trusts.
Then prove you kept it. plank_deck_qa.py takes the deck you ported FROM and reports what the port lost:
python3 /workspace/scripts/deck/plank_deck_qa.py ported.pptx --source their-original.pptx
It compares page size, typeface, the display italics, the accent and the picture count, and reports each as a fidelity-* finding against the deck as a whole.
It also asks the other question, which is the one that gets a proposal thrown out: is everything on it true of this client? Where the geometry lets it match a slide to the one it came from, it reports two port-* findings. port-stale-figure is a slide whose heading changed while a figure on it did not — a named director carrying the previous candidate's day rate, which is the kind of error a client finds before you do. port-stale-link is a hyperlink whose words were rewritten while its destination was not — which is how a ported talent slide keeps pointing at the previous director's showreel, and it is invisible on the slide. port-unchanged-slide lists the slides that came across with nothing rewritten; that one is info, not a warning, because leaving a slide alone is often right — a production company's own credentials do not change with the client — and no measurement can tell that apart from a slide nobody got to. Read them.
Neither of these fires on a rebuilt deck, because a rebuild matches nothing. That is correct: it has larger problems, and they are the fidelity-* findings above. These are whole-deck aggregates on purpose — matching slide 4 to slide 6 is a guess, and a wrong mapping produces confident nonsense, while counts and sets catch the failure that actually happens. A port that was edited from the source comes back with none of them; a port that was rebuilt lights up. Run it before you hand anything over, and treat a fidelity- error as what it is: not their deck any more.
Which format: .pptx or HTML → PDF?
Ask the format question — see the question table, where it is the one question exempt from the three-to-five cap — then route on the answer. Say which one you chose and what it costs — in one line, in the reply that delivers the deck. A user who wanted to tweak a headline and got a PDF has been let down by a decision nobody told them about.
| The deck is… | Build it with | Because |
|---|---|---|
| A pitch, a brand deck, a launch, a conference talk — it has to land | HTML you design yourself → PDF, the method above | Full-bleed imagery, layered scrims and a real type engine, with an identity taken from the subject. This is the ceiling. plank_slides.py is the fallback when the deck has to be geometry-checked. |
| A board pack, a financial review, a monthly report | either — ask | Usually .pptx, because these get re-cut. HTML if it is presented once and archived. |
| Something the customer will edit, re-brand, or reuse as a template | plank_deck.py → .pptx | A PDF is finished. If "send me the deck" means "send me something I can change", it is this one. |
| Something that must open in PowerPoint on somebody's laptop | plank_deck.py → .pptx | Obviously. |
What each one gives up, stated plainly:
- HTML → PDF is not editable. No shapes to nudge, no chart data to double-click, no text to retype. Changing anything means changing the build script and re-printing. If that is not acceptable, it is the wrong format — and the way to find out is to ask before you build, not after.
.pptxis less visually capable. It draws shapes. A photograph can only carry a single linear wash over it, a card is a flat rectangle with hard corners, and there is no blend mode, no gradient mesh, no sub-point hairline. It looks like a good deck; it does not look art-directed.- HTML → PDF also needs a renderer this workspace might not have. It prints with headless Chromium. Run the availability check above before you build: empty means this workspace cannot print HTML, so say so plainly and build
.pptx— you lose the full-bleed look, the deck still ships.
Everything else is shared: same studio/report themes, same validated palettes, same 12-column grid, same primitives with the same names and signatures, same text budgets, same QA tool. Switching a finished deck script from one to the other is a change of import line.
There are two HTML routes, and they trade the same thing in opposite directions. plank_slides.py writes the HTML and a .slides.json manifest beside it, which is what lets plank_deck_qa.py measure every word position, every overflow and every collision — at the cost of the design being the house style rather than one derived from the subject. Designing the HTML yourself — the method above — is the ceiling for how a deck can look, and it has no manifest, so no tool will ever measure its layout. That is a real trade, not a preference: on the hand-authored route the render-and-look pass is not optional, it is the only geometry check that exists. Take it for a pitch or a launch; take plank_slides.py when the deck is routine and being checked matters more than being memorable.
What you get
- A
.pptxor an HTML/PDF deck at 16:9, the standard widescreen aspect ratio. - The art-directed dark look by default — near-black surfaces, italic display type, the Plank blue accent used sparingly, full-bleed photography where you have a picture worth showing, and a persistent header/footer carrying your brand. The light typographic style is one argument away:
new_deck(theme="report"). - Native, editable charts — not images of charts. Double-click one in PowerPoint and its data is right there.
- Speaker notes on every slide, not just the ones that seem to need them.
- Text set in Inter and JetBrains Mono, resolved on whatever machine opens the file. A machine without those fonts installed substitutes its own — the deck still opens and reads correctly, just in a different typeface.
The house style
Two themes, and the dark one is the default
new_deck() builds in studio; new_deck(theme="report") builds in report.
studio (default) | report | |
|---|---|---|
| Looks like | A pitch, a brand deck, a launch | A board pack, a financial review, anything printed |
| Surface | Obsidian #0A0A0F, card #1A1A22, hairline #2E2E3A | Warm ivory #FAFAF7, card #F0EDE8, hairline #DDD8D0 |
| Ink | #FAFAF7, soft #A2A2AE | #3A3A42, soft #6B6B75 |
| Accent — rules and bars | Plank blue #4F6DF5 | Plank blue #4F6DF5 |
| Accent — small labels | #8FA6FF (8.5:1 on obsidian, 7.5:1 on the card) | #3F58CE (5.7:1 on ivory, 5.1:1 on the card) |
| Display type | Italic | Upright |
| Signals | good #34D399, bad #FF6B6B, warn #F5B02B | good #34D399, bad #EF4444, warn #F59E0B |
The dark theme's bad is a lighter red than the light theme's on purpose: #EF4444 is only 4.0:1 on obsidian.
Both themes accent in Plank blue. They differ in surface, type style and imagery — not in brand colour. What differs is the lightness step used for small type, and that is a contrast requirement rather than a style choice: at 9pt and 11pt the accent is body text, so it needs 4.5:1, and plain #4F6DF5 gives only 4.55:1 on obsidian and 3.98:1 on the dark card — under the floor exactly where the step numbers and card labels sit. The light theme had the same problem in the other direction and slightly worse (4.15:1 on ivory, 3.72:1 on stone). So each theme carries one extra step of the identical hue — lighter on dark, darker on light, both within 2.3° of Plank blue in OKLCH. Rules and bars are large shapes with a 3:1 floor and stay pure #4F6DF5 in both.
Over a photograph both themes use #8FA6FF, because a scrim can only guarantee light type on a dark wash.
What a theme is not allowed to change is geometry, type sizes or budgets. They are shared, so the same deck script builds in either look and there is exactly one budget table below to keep true. Rebuilding a deck in the other theme changes its colour, its type style and whether it carries photographs — never what fits on a slide.
Choosing between them. Persuasion takes studio; scrutiny takes report. A deck that will be printed, photocopied, read on paper or projected onto a badly-lit wall is a report deck. If the user asks for "the light version", "как раньше" or a deck to print, pass theme="report" and say you did.
Chart series palettes — fixed order, do not reorder or substitute
There is one per surface, because colorblind-safety is a claim about a palette and the ground it is drawn on, and it does not transfer between them. Each was validated in this exact sequence. Every chart primitive assigns from its theme's list in order.
studio, on obsidian #0A0A0F — worst adjacent CVD ΔE 16.1 (protan), worst normal-vision ΔE 18.2, all six ≥3:1 contrast, all six inside the dark-mode OKLCH lightness band 0.48–0.67: #4B8EFF blue, #C08A1F ochre, #00A4BF teal, #30AE40 green, #9B5CD7 violet, #DE5077 rose.
report, on ivory #FAFAF7 — worst adjacent CVD ΔE 15.1, worst normal-vision ΔE 15.6, all six ≥3:1 contrast: #4F6DF5 blue, #C2701F ochre, #0E9F9F teal, #4C7A2E green, #9B5DE5 violet, #C2456B rose.
The dark list was re-derived from scratch, not lightened by eye. The ivory list fails the dark-mode lightness band outright, and its ochre and green fall under 3:1 on near-black — so "the same colours, a bit brighter" would have shipped a palette with no guarantee behind it.
Type. Inter throughout; JetBrains Mono is reserved for tabular data — table body cells, where a column of figures needs to line up — and does not extend to a KPI's own value or delta, which have nothing to line up with (see below). Cover title 44pt/700, tracking −0.03em. Slide title 30pt/700, tracking −0.03em, bottom-anchored in a box tall enough for two lines plus a 6pt inset underneath them, so a long title grows upward and never collides with the accent rule under it. That inset is load-bearing: a rendered glyph sits slightly below its own line box, and with the box ending exactly where the type ended, every titled slide in every deck put its title's descender outside the shape that owned it. Raising the box height alone does nothing about it — bottom-anchored text moves down with the box bottom — so the space has to be reserved as an inset the type cannot use. Section-divider label 40pt/700, tracking −0.03em — sized to match the closing slide, not the smaller eyebrow caption it started as. Statement 32pt/700. Body text 16pt/400. KPI value 40pt/700, Inter. Eyebrow labels (section numbers, compare-card labels, hero eyebrows) 11pt/700, tracked +0.08em. Cover footers, closing contact lines and quote attributions take the same tracking at 400 in ink-soft — they are quiet metadata, not headings, and the builder does not embolden them. The brand chrome runs quieter still at 9pt, tracked +0.06em.
Display type is italic in studio and upright in report — covers, slide titles, statements, section labels, quotes, hero titles and closings. It is the cheapest single thing that makes a deck read as art-directed rather than as a template, and because it is a style and not a size it costs the budgets nothing measurable: Inter Italic's advance widths sit within 1% of the upright, well inside the 9% headroom every proportional estimate already carries.
Geometry. 13.333in × 7.5in (16:9). Margins 0.75in left/right, 0.6in top, 0.7in bottom. A 12-column grid with 0.2in gutters — every primitive places itself on this grid, never at an arbitrary coordinate.
Two chrome bands are reserved on every slide, whether or not the deck sets a brand: a 0.24in metadata row starting at the top margin (0.60in), and a 0.25in footer ending at the bottom margin (6.55–6.80in). They sit inside the safe margins, not in them — content in the margin is cropped by projectors, and the QA pass calls it an error. Reserving them unconditionally is deliberate: a deck built without a brand lays out identically to the same deck with one, so adding a logo late does not silently re-flow every slide, and there is one budget table instead of two that have to agree.
The title band therefore starts at 0.94in, the accent rule sits at 2.17in, the body band runs 2.44in → 6.55in, and source notes live in their own strip at 6.24–6.49in, immediately above the footer.
Vertical rhythm. Under the accent rule there is a 4.11in body band, and every body primitive is measured against it rather than against the slide. A block that ignored it — a fixed-height KPI tile, a fixed-height process card, a three-row table — used to end between 48% and 57% down a 16:9 slide with nothing under it, which reads as unfinished rather than as deliberate whitespace. Blocks still start at the top of the band, never lower: floating a row an inch below the accent rule makes it read as unrelated to its own title. They grow downward into it two ways, depending on what sets their height:
- Cards — KPI tiles and process steps — have a height the content doesn't set, so the layout sets it: 3.29in, with the card's own content centered inside. Not the full band: at 3.70in a tile's three short lines float in the middle of a mostly empty surface, which doesn't fix the hole in the slide, it moves the hole inside the card. A process card also runs with its step count, because two steps are 5.7in-wide cards and a step carries far less ink than a 40pt KPI value.
- Rows — bullets, compare points, table rows — are a count times a pitch, so the pitch stretches to fill the band and stops at a cap. Content already filling the band is spaced exactly as it was; short content gets more air, not bigger type. The cap is the point: two bullets on a slide should be two well-spaced bullets, never two enormous ones.
A block that fills the band reaches 3.70in and stops just above the source-note strip — that is what the fill fraction is for, and it is why a chart is 3.70in tall rather than the 3.90in it was before the chrome bands existed. Emptiness left under genuinely short content is correct and is left alone; see Whitespace.
Slide types
Fourteen primitives, each a function in the builder below. Reach for the one that matches what the slide is actually for — don't force content into the wrong shape to avoid learning a new primitive.
cover— the opening slide: title, optional subtitle, presenter and date.section_divider— a numbered beat between sections of the talk, full-bleed surface.statement— one bold claim, centered, nothing else competing with it.bullets— a titled slide with up to six points, each with as many optional sub-points as fit inside the 8-line total budget.compare_two— two labeled cards side by side, for a before/after or an A-versus-B.kpi_row— two to four headline numbers in tiles, each with a label, an optional delta, and an optionaltonethat colors the delta.table_slide— a data table with an optional source note underneath.chart— a native, editable chart; you pick it by what the reader should take away, not by which chart type looks nice.quote— a single quotation with its attribution, full-bleed surface.labelled_blocks— two to four labelled cards, drawn as a grid (2×2 for four), with an optional eyebrow above the title's rule and an optional lead under it. The shape for a set of peers: four capabilities, four guarantees, four markets. Reach for this rather thanbulletswhenever each point has a NAME as well as a sentence — a flat list of four is read as an ordered list, and they are not ordered.process— up to five numbered steps laid out as a row of cards. A step is a string, or{"label": …, "text": …}when the stage has a name worth setting apart from its description.hero— a full-bleed photograph with the title over it, plus an optional eyebrow and lead line. The opening of a pitch, the beat before a section, the one slide that has to land as an image rather than as an argument.image_content— the picture on the right five columns of the body band, up to four points on the left seven. An argument that needs a picture beside it rather than under it.closing— the final slide: headline, optional call to action, optional contact line.
hero and image_content are the two that take a file path. See Images below — the rules there are not optional.
Text budgets
Every primitive measures its text against the box it has to fit in and raises DeckError rather than trusting anyone to remember a number. The measurement happens before anything is drawn, so a slide that cannot hold its content is refused instead of being built and then discovered later. Going over budget means the slide is carrying two ideas, not that the font should shrink to fit — the fix is to shorten the text or split the slide, never to reduce the type size.
The budgets are the check that runs while the deck is being written, and not the only one: once the file exists it can be rendered and looked at — see Checking the deck.
A budget is a measurement, not a character count. Each character costs an estimated fraction of an em — 0.52 for Latin lowercase, digits and punctuation, 0.58 for Cyrillic, 0.68 for uppercase in any script, 1.0 for CJK and emoji, 0.26 for a space — plus about 9% headroom. JetBrains Mono runs are billed at their exact 0.60em advance instead, with no headroom, because a monospace font has no wider glyph to miss. The builder then wraps the string into its real box the way a renderer does, and raises if it needs more lines than the box has.
The practical consequence: the same box holds roughly 10% fewer Cyrillic characters than English ones, and fewer again if the text is uppercase. That is why the table below gives two numbers. Neither is the rule — the measurement is. The old single number was calibrated on English prose and was shipped, on this page, to Russian readers; a Russian line at the English limit overflowed its card.
Two more rules the check enforces:
- No line breaks or tabs. Every budgeted string is a single line by construction.
"Q1\nQ2"is five characters and passes any cell budget, and it doubles the height of a fixed-height row. - No unbreakable token wider than one line. Word wrapping cannot break inside a token, so a long URL, a product code, or a compound like
Перерасчёт-отчётностиruns off the side of a narrow card however short the whole string is. Break it with a space or a hyphen.
| Slide part | Approx. capacity (English / Russian) |
|---|---|
| Cover block (title + subtitle) | The two share ONE frame, so they share one budget and it is measured on the pair. Title up to 6 lines of ~26 / ~23 chars (editorial cap 90); subtitle up to 2 lines of ~67 / ~60 (cap 140) — but what decides the slide is the block against the space above the presenter strip. If the title fits alone and the pair does not, the subtitle is dropped and the cover is still drawn, with a Python warning naming what went. A cover is the one slide whose absence is worse than any compromise in it |
| Cover footer, closing contact | one line, ~127 / ~115 chars |
| Slide title | 2 lines of ~52 / ~46 chars, and at most 80 chars — the title box is bottom-anchored and grows upward, so the accent rule and body below it never move. The box is 1.10in of type plus a 6pt bottom inset that type may not use. On image_content the title only has seven columns: 2 lines of ~27 / ~24 chars |
| Section divider label | one line, ~33 / ~29 chars |
| Statement | 4 lines of ~39 / ~35 chars, and at most 140 chars |
| Bullets | 6 items max; 8 lines total across bullets and sub-bullets combined, counting the lines each one wraps to — so a bullet may run long, it just spends more of the eight. ~75 / ~67 chars per line of bullet (the em-dash marker counts), ~81 / ~73 per line of sub-bullet |
Compare (compare_two) | 5 points per side max. A point may wrap; what is budgeted is the body band, so each side may spend 8 lines across its points however it likes. ~44 / ~39 chars per line, ~53 / ~48 per card label. The two cards are a matched pair and both take the taller side's height |
| KPI row | 2–4 tiles. The value, the label and the delta share ONE stack inside the tile, and 2.45in of it is what is budgeted — a 40pt value line is three label lines tall, so no per-field line count can express the trade. In practice that is a one-line value plus up to 8 label lines (7 with a delta), which at 4 tiles is roughly 130 / 115 characters of label. The label was capped at one line until 2026-09-18, which is about 12 Cyrillic characters and cannot say what a number means. The delta's colour comes from the tile's optional tone (good / bad / neutral), never from its sign |
| Table | 6 columns × 8 rows max. A cell may wrap and its row grows to hold it; what is budgeted is the table's total height against the body band (3.70in), where a one-line row costs 0.30in and each extra line 0.20in. So four rows with a two-line column fit and eight do not, which the old one-line-per-cell rule could not tell apart. A cell may not exceed 4 lines. Header (Inter): ~41 / ~36 chars at 3 columns down to ~18 / ~16 at 6. Body cell (JetBrains Mono, exact): 36 per line at 3 columns down to 16 at 6. A row with the wrong number of cells is rejected outright |
| Process | 5 steps max. The card is content-sized: a step may use as many lines as the body band holds (9 at most — 8 when the step carries a label), and the card grows to fit the fullest one, no further. A short row is not stretched to a layout height; it sizes to its content and the row sits optically in the band. An optional per-step label is one line, at most 40 chars, and costs the step one of its own. Per line, from the card width: ~46 / ~41 chars at 2 steps, ~28 / ~25 at 3, ~19 / ~17 at 4, ~14 / ~12 at 5. There is no character cap — the old flat 32 refused about a third of what the card could carry |
| Quote | 4 lines of ~49 / ~44 chars, and at most 180 chars; attribution one line, at most 60 |
| Closing title | 3 lines of ~27 / ~24 chars, and at most 60 chars; CTA 2 lines, at most 90 |
| Chart source note | one line, ~147 / ~131 chars |
| Chart category labels | one line each, sharing the plot width: ~33 / ~29 chars at 4 categories, ~22 / ~19 at 6 |
Hero (hero) | Title 3 lines of ~27 / ~24 chars, and at most 60 — tighter than a slide title's 80 on purpose, because a long sentence on top of a photograph is read by nobody. The box is bottom-anchored like a slide title's and holds all three lines: 2.07in of type (three 48pt line boxes plus 4.8pt for the ascender overshoot) plus the same 6pt bottom inset. It used to be 1.86in, which is 2.7 of them, and a 54-character Russian title — well inside the cap — escaped 14.8pt out of the top of it. Eyebrow one line, at most 40 chars, and it sits above the first line of the title rather than above the box, so it does not float when the title is short. Lead 2 lines of ~67 / ~60, at most 110 |
Image + content (image_content) | 4 points max. A point may wrap; what is budgeted is the body band, so the points may spend 8 lines between them however they like — the same eight bullets gets, because it is the same band at the same size. ~58 / ~52 chars per line (the em-dash marker counts). Caption one line, ~60 / ~53, at most 70 |
Labelled blocks (labelled_blocks) | 2–4 blocks. Label one line, at most 40 chars. The card is content-sized and every card takes the tallest one's height, so the budget is the whole block — lead, cards and the row gutter — against the body band (4.11in; this primitive draws no source note, so the strip reserved for one is not reserved against anything here). On a 2×2 the lead costs BOTH rows, so a one-line lead buys every card a line of its own. Eyebrow one line, at most 48 chars, and it takes the accent rule's slot: there is nowhere above the title, because the brand metadata row ends at 0.84in and the title box starts at 0.94in |
| Brand chrome | Metadata row one line, ~112 / ~101 chars — it shares the top band with the logo, which is sized by height and under an inch wide, so the row gets nine columns rather than the six it used to. Footer line one line, ~107 / ~95. Both are measured once, in set_brand, not per slide |
The character caps above (40, 60, 70, 80, 90, 110, 140, 180) are editorial limits on the display-tier slides, applied on top of the measurement: a cover title that long is a writing problem before it is a layout one. The body-tier primitives — bullets, compare, process — carry no character cap at all; they are measured against the band and nothing else, because a body slide going long is a layout question and the layout can answer it.
Images
Two primitives take a picture: hero(prs, image, title, eyebrow=None, lead=None, notes="") and image_content(prs, title, image, points, caption=None, notes=""). Both take a path to a file that already exists in the workspace — the builder consumes images, it does not make them.
Confirm the file is there before you reference it. The builder checks too, and refuses with a DeckError before the slide is created rather than after — a missing image is the one thing in a deck that fails silently, as an empty frame that looks like a design choice. Paths are relative to wherever the build script runs, so keep the script and the images in the same place and use the same relative path you would use to open the file.
Text over a photograph always gets a scrim, and this is structural. Both primitives lay a dark gradient wash between the picture and the type, and there is no argument that turns it off. That is not a stylistic preference — it is the only thing that makes the type legible against a picture nobody has vetted, and "pick a darker photo" is not a guarantee when the next photo is a coin toss. Text over an image is always light type on that wash, in both themes: a photograph is not a surface whose colour anything knows, so the light theme does not put graphite on it.
The wash is heaviest across the bottom third, where the title, the lead and the footer sit, and nearly clear across the middle so the picture is still a picture. image_content uses a narrow strip under its caption instead of washing the whole image, for the same reason.
And it is checked. plank_deck_qa.py rasterises every slide, so for each word rendered over a picture it reads the pixels that actually ended up behind it and computes the real WCAG contrast ratio. Under 3:1 is an error; 3:1 to 4.5:1 is a warning. See Checking the deck.
If you hand-place a text box over a picture rather than using these primitives, you own the scrim yourself — and the check will tell you if you got it wrong.
Charts
chart(prs, title, goal, categories, series, source, notes) picks the chart type from goal, not from taste — say what the reader should take away and the builder picks the shape that shows it:
| Goal | Chart type |
|---|---|
comparison | clustered column |
trend | line |
composition | stacked column |
part | doughnut |
progress | bar (ranked, reading top-to-bottom) |
Native charts are vector and fully editable — a reader can click into the data — and they inherit the deck's fonts and series palette automatically. The chart title states the takeaway ("Support costs fell 40% after triage"), never just the dimension being plotted ("Support costs"). Every chart carries a source note, and that note is measured like any other string.
Bar and column charts are pinned to a zero baseline. comparison, composition and progress set the value axis minimum to 0, so a small difference can't be exaggerated by a truncated axis — bars encode length, and a length measured from an arbitrary floor lies. trend is deliberately left on PowerPoint's autoscale: a zero baseline on a time series usually flattens the very movement the chart exists to show.
goal="part" draws one doughnut ring: it rejects more than one series (a ring can only show one), and rejects more than six categories, since a doughnut colors its slices and the validated palette has six slots — group a long tail into "Other" rather than adding a seventh slice. Every other goal takes at most twelve categories: past a year of months the labels overlap and the chart is really a table.
Ragged data is rejected, not silently drawn. A series whose value list is shorter than the categories plots partial data; a longer one drops its tail. Both raise, as does a value that is neither a number nor None (which is a deliberate gap). table_slide refuses a ragged table the same way: every row must carry exactly as many cells as there are headers.
Charts follow the type split stated above: JetBrains Mono only in table body cells, Inter everywhere else — headers, source captions and axis labels included. Mono's fixed advance works against a display number, opening a visible gap around a comma or a leading minus and a wider one before a Cyrillic unit, which is why a KPI's value is Inter too.
If what you need can't be expressed as a native OOXML chart — a waterfall, a gantt, a sankey, an annotated combo chart — fall back to matplotlib rendered to a PNG and placed as an image. Do this knowingly: Plank workspaces have no fonts installed, so matplotlib renders in its default DejaVu Sans, and the result will not match the rest of the deck's typography. Use it only for the shapes OOXML genuinely cannot express, not as a shortcut around the native chart primitive.
Writing the slides
Everything above makes a slide well-formed. None of it makes a slide good. A deck can clear every budget and still open with a title that names a topic instead of stating a finding, spend six slides making one point, and bury the decision on slide nine. These are the rules the code can't check.
These rules are the deck-specific narrowing of a cross-format standard that ships as two installable skills — Deliverable Writing and Deliverable Design, which extend the same bar to HTML documents and dashboards. For a deck, this page wins on deck specifics and carries the worked examples. If either skill is installed in this workspace, invoke it as well before you deliver. See Skills.
Open with the answer, then spend the deck proving it. The first slide after the cover states the conclusion. Everything after it is support: the arguments that hold the conclusion up, and under each argument its evidence. Never build to a recommendation over twenty slides — the room decides in the first minute whether it is listening, and a reader who already has the answer reads what follows as proof rather than waiting to find out why they are in the room.
| A deck that opens | Reads as |
|---|---|
| Market overview → Our services → Case studies → Our proposal | Four slides in, nobody knows what is being asked of them |
| Manual re-counting costs us ₸40m a year → Where the loss comes from → The pilot removed two of the three sources → Approve the rollout by 1 October | The finding first; the rest is why it is true and what to do |
The title states the finding, not the subject. The title carries the claim; the body carries the evidence. Read the titles alone, top to bottom, and you should have the argument — starting with the first one, which should already be the answer and not a promise of one.
| Instead of | Write |
|---|---|
| Support costs | Support costs fell 40% after triage |
| Q3 results | Q3 revenue grew 12%, all of it from renewals |
| Hiring plan | Two more analysts before October, or Q4 slips |
One idea per slide. This is the same rule the budgets enforce from the other side: when the text won't fit, the cause is almost always two ideas sharing a slide, not a font that's too big. Split it, and give each half a title that states its own finding.
Every number needs a baseline and a direction. "40%" measures nothing. "40% lower than Q2" is a finding. Units always, and always say what the number is measured against — last quarter, plan, the other vendor. A KPI delta follows the same rule: -40% vs Q2, not -40%.
And say whether the direction is good news — the sign can't. kpi_row takes an optional tone per tile, one of "good", "bad" or "neutral", and that is the only thing that colors the delta:
{"value": "-2.0", "label": "Handoffs per ticket", "delta": "-2.0 vs Q2", "tone": "good"}
{"value": "-12%", "label": "Revenue per account", "delta": "-12% vs Q2", "tone": "bad"}
{"value": "2.4x", "label": "Tickets per day", "delta": "2.4x vs Q2"}
A delta with no tone is neutral ink, whatever its sign. The builder used to read the sign — leading + green, anything else red — and painted -2.0 handoffs per ticket, -31% churn and -40% cost in the same error red as a failure. For a large class of business metrics down is the win, so the sign genuinely cannot tell: -40% is a win on cost and a loss on revenue. The builder no longer guesses, because guessing is what produced that bug, and a wrong color is worse than no color — a neutral delta still reports the number; a red one reports a failure that didn't happen. If a delta deserves green or red, say so with tone. A misspelled tone raises DeckError rather than silently falling back.
Bullets on one slide take the same grammatical shape. Same shape means the reader compares content instead of untangling wording. Not "Costs down 18% / We hired two analysts / Latency improvements", but "Cut costs 18% / Hired two analysts / Cut p95 latency 40%".
Say the "so what". Name the decision or the action, and name who owns it. "Approve two hires by 15 September" is a so-what; "we should consider hiring" isn't. A deck that ends without one is a report, and a report doesn't need slides.
Cut the filler. "Overview", "Introduction", "Next Steps" name a slot in a template, not a finding — retitle the slide with what it actually says. No warm-up sentence before the first point, and no bullet that restates the title in other words.
Speaker notes carry the nuance the slide can't. The builder refuses to build a slide without them, so use them for something: where the number came from, the caveat it would be dishonest to leave out, the answer to the obvious objection, the figure you'd quote if asked. Not a transcript of the bullets — the audience can read those.
Making it look considered
A budget proves the text fits inside its box, and nothing else. Balance, hierarchy and whether the deck holds together are judgement. These are the habits that stand in for the look you can't take.
Hierarchy is size, weight and placement — in that order. The headline is visibly larger than what supports it, not a point or two larger, and the takeaway sits where the eye starts: upper-left for a Russian or English reader, dead centre on a statement. The primitives already do this. A hand-placed box that parks the conclusion bottom-right undoes it.
One focal point per slide, and the eye should reach it first. Decide what the reader should see first — the number, the chart, the claim — and let everything else support it. If two elements compete for that job, one belongs on its own slide or in the notes.
Two typefaces, and no colour from outside the theme. The house style ships exactly two faces (Inter, JetBrains Mono) and two validated six-colour series palettes, so the rule is really do not add: no third typeface, and no hex that isn't a theme token or a series colour. Weight and size make hierarchy inside one family — that is what they are for. The six series colours are meaning, not decoration: assigned in a fixed, validated order so a colour keeps its meaning across the deck. If a slide needs emphasis, use position, size or whitespace. plank_deck_qa.py counts the typefaces and warns on a third; colour it leaves to you, because a workspace can legitimately override primary and accent and the tool can't tell that from a slide painted red for effect.
Layout follows the shape of the content — so pick the primitive, don't invent a layout. Text with a picture beside it is image_content; a claim against a counter-claim is compare_two; grouped metrics are a kpi_row of two to four; ordered stages are process. Reaching for a new arrangement almost always means the content wants a primitive that already exists.
Whitespace is deliberate, not leftover. A crowded slide reads as unfinished even when every string fits — and so does the opposite, a small block of content clinging to the top of an empty slide. The primitives handle the second case for you by growing into the body band, but they can't invent content: if a slide is still half empty, it wants another point or a different primitive, not a taller box. Sit on the 12-column grid and repeat spacing rather than tuning it per slide — consistency of spacing, type size and colour is most of what separates a professional deck from an amateur one, and a reader notices an inconsistent gap long before an imperfect one.
Type can be the picture. A deck without photographs is not a lesser deck. Large expressive type as the visual driver — statement, section_divider, a cover carrying nothing but the claim — is a real alternative to imagery, and usually the better answer for a financial or reporting deck. It is also the answer when the imagery question comes back "none".
Charts stay legible. The chart rules above already cover the takeaway title, the zero baseline and the source note — follow them. Beyond that: name a series in words the reader doesn't have to match to a colour. A single series draws no legend at all, so put its name in the title ("Weekly signups, 2026"); six series and a bottom legend means the reader is doing matching work, so split the chart or group the tail into "Other".
Consistency across the deck. The fourth KPI slide should look like the first: same primitive for the same job, same order of elements, same wording of labels.
The builder
Don't transcribe it — download it:
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck.py https://plank.md/help/assets/plank_deck.py
It goes in /workspace/scripts/deck/plank_deck.py — that exact directory, and that exact name: the underscore matters, since import plank-deck is a Python syntax error. Put your own build_*.py in /workspace/scripts/deck/ too, so from plank_deck import * resolves with no PYTHONPATH and no cd — a bash call does not inherit the previous call's directory, which is why a bare relative filename is the one thing that reliably breaks here. Never hand-write a file called plank_deck.py: it imports, it measures nothing, and every later build in the workspace picks it up instead of the real builder. Short version of all of this: Build a deck: the route.
Every call, and the shape of every argument
This is the whole surface. You do not need to read the source to write a deck — and you should not, it is two thousand lines and reading it costs more than building the deck. curl it, call these, and read the file only if a DeckError sends you somewhere this table does not cover.
new_deck(theme="studio") # or "report"; returns prs
set_brand(prs, meta=(), logo=None, confidential=None, city=None,
year=None, slide_numbers=True)
save(prs, path) # returns path
cover(prs, title, subtitle=None, presenter=None, date=None, notes="")
section_divider(prs, number, label, notes="")
statement(prs, text, notes="")
bullets(prs, title, items, notes="")
compare_two(prs, title, left, right, notes="")
kpi_row(prs, title, kpis, notes="")
table_slide(prs, title, headers, rows, note=None, notes="")
chart(prs, title, goal, categories, series, source, notes="")
quote(prs, text, attribution, notes="")
process(prs, title, steps, notes="")
hero(prs, image, title, eyebrow=None, lead=None, notes="")
image_content(prs, title, image, points, caption=None, notes="")
closing(prs, title, cta=None, contact=None, notes="")
Every slide takes notes and every slide requires it. A slide built without speaker notes is refused — that is deliberate, and it is the one guard here that is about the deck being presentable rather than about it fitting. Write the note you would actually say out loud.
The compound arguments, with their limits:
| Argument | Shape | Limit |
|---|---|---|
bullets(items) | "point", or ("point", ["sub", "sub"]) | 1–6 items, 8 lines total |
kpi_row(kpis) | ("value", "label"), or {"value":…, "label":…, "delta":…, "tone":…} | 2–4 tiles |
compare_two(left/right) | ("label", ["point", …]), or {"label":…, "points":[…]} | 1–5 points a side |
table_slide(headers, rows) | ["Header", …] and [["cell", …], …] | 1–6 columns, 1–8 rows |
chart(categories, series) | ["Q1", …] and [("Series name", [1, 2, 3]), …] | one series per validated palette slot; goal="part" takes exactly one |
process(steps) | "step text", or {"label":…, "text":…} | 1–5 steps |
labelled_blocks(blocks) | ("label", "text"), or {"label":…, "text":…}; plus eyebrow= and lead= | 2–4 blocks |
image_content(points) | "point text" | 1–4 points |
tone on a KPI tile is "good", "bad" or omitted, and colours the delta only. goal on a chart is what the reader should take away, not a chart type — see Charts.
A DeckError means the slide is carrying too much, not that the font should be smaller. Every primitive measures its text against the box before it draws anything, so a refused call adds no slide at all and leaves nothing to clean up. Split the slide, cut the copy, or move the detail to the notes.
The HTML slide builder
When the genre says HTML → PDF, the builder is plank_slides.py. It needs plank_deck.py in /workspace/scripts/deck/ beside it — it imports the themes, the grid, the budgets and the chart-goal vocabulary from it rather than restating them, which is what guarantees the two paths cannot drift apart:
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck.py https://plank.md/help/assets/plank_deck.py
curl -sSfL -o /workspace/scripts/deck/plank_slides.py https://plank.md/help/assets/plank_slides.py
from plank_slides import *
deck = new_deck(lang="ru") # or new_deck(theme="report", lang="en")
set_brand(deck, meta=(("brand", "ACME"),), city="Almaty", year="2026")
cover(deck, "Складская логистика без ручного пересчёта",
subtitle="…", presenter="…", date="…", notes="…")
hero(deck, "photo.png", "Каждый час склада считают вручную",
eyebrow="ПРОБЛЕМА", lead="…", notes="…")
save(deck, "deck.html")
Every primitive on this page exists here under the same name with the same arguments, and refuses the same over-long text — the budgets are imported, not re-implemented. Three differences worth knowing:
new_deck(lang=…). Sets the document language so a browser hyphenates and quotes correctly. It changes no geometry.cover(image=…). The HTML cover can carry a full-bleed photograph, which the.pptxcover cannot.image_contentbleeds. The picture reaches the top and right edges of the slide instead of sitting in a box, with its own scrim protecting the metadata row.
save() writes two files: deck.html, and deck.slides.json beside it. The second is the authored geometry of every block — the thing a .pptx carries inside itself — and the QA tool needs it to tell you a word left its box. Keep them together; hand over the PDF.
The .html has two geometries, and you don't have to do anything about it. Opened in Plank — or in a browser, or on a phone — the slides scale to fit the width they're given and stack one above another, so the deck is read by scrolling; nothing scrolls sideways and nothing is cropped, at any window size. That is screen only. Printed, every slide is still exactly 1280×720, one slide per page, which is what makes the PDF the fixed-size artefact it has always been — the scaling is inside @media screen and cannot reach it. It is plain CSS, so it works with JavaScript off and the file stays self-contained. The deck never scales up: past 1280px wide it centres at full size rather than blowing the type up.
The PDF comes out of the QA pass, not the builder, exactly as it does for a .pptx. That is deliberate: the PDF you hand over is by construction the one that was checked.
python3 /workspace/scripts/deck/plank_deck_qa.py deck.html # prints deck.pdf, then inspects it
Your own brand
A workspace can steer the house style by adding a ## Brand block to its AGENTS.md:
## Brand
- theme: studio
- primary: #0B5FFF
- accent: #F2A65A
- fonts: Söhne / Söhne Mono
- logo: assets/acme-logo.png
- footer: ACME · Confidential · Almaty · 2026
- meta: brand ACME / agency Plank Studio
That block is guidance the assistant reads and applies by hand. Nothing parses it — so it is not how you set a customer's colours. It is a place to record the brief. Two of its keys now have a mechanical route, and you should use it.
The colours: new_deck(accent=, ink=, ground=)
prs = new_deck(accent="#DAFF00", ground="#000000", ink="#FFFFFF")
ground is the page, ink is the type on it, accent is the one colour that is theirs. Everything else — the card surface, the hairline, the softer ink — is derived from those three, so the palette stays a palette rather than three colours dropped into somebody else's. Pass one, two or all three; anything you leave out keeps the base theme's.
Every derived pair is measured before the theme exists, against the same WCAG floors plank_deck_qa.py measures the render with: 4.5:1 for ink, the soft ink and small accent labels, on both the ground and the card; 3:1 for the accent as a rule or a bar. A pair that fails raises a DeckError naming the pair and the ratio, which is a fixable brief. The alternative is a deck that is built, handed over and only then found to be unreadable.
One pair fails more often than the rest: a brand accent chosen for a logo is frequently too dark to set 9pt type in. That is what accent_text= is for — a lighter or darker step of the same hue, leaving the graphic accent alone. It is exactly what both built-in themes already do (#4F6DF5 as a rule, #8FA6FF as a label on obsidian).
prs = new_deck(theme="report", accent="#0B5FFF", accent_text="#0A47B8")
brand_theme(...) is the same thing as a value, if you want to build the Theme once and hand it to several decks: new_deck(theme=brand_theme(accent="#DAFF00", ground="#000000", ink="#FFFFFF")).
The chart series palette is not derived and does not move. Its six colours were validated colorblind-safe as that exact sequence on that exact surface; a brand accent is a different problem and three inputs cannot solve it. If a customer has a real chart palette, pass series= on Theme directly and accept that the validation is yours now.
The chrome: set_brand(...)
The chrome is one call, and it is separate from the colours above on purpose: a logo and a footer are content, a palette is a theme.
prs = new_deck() # or new_deck(theme="report")
set_brand(prs,
meta=(("brand", "ACME"), ("agency", "Plank Studio")),
logo="assets/acme-logo.png",
confidential="Confidential", city="Almaty", year="2026")
Every slide then carries the metadata row and the logo lockup across the top, and the confidentiality line, city, year and slide number across the bottom. Call it once, before the first slide. A deck with no set_brand still numbers its slides — the bands are reserved either way.
Four things to know before you override:
themepicks betweenstudioandreport. That is the one key that is not a colour, and it changes more than a colour does. See Two themes.primaryandaccentin the## Brandblock are a brief;new_deck(accent=…)is the mechanism. Read the block, then pass the colours. They move the accent rule and the eyebrow labels and never touch the chart series palette — see above for why.- An accent that works on your surface may not work over a photograph. Plank blue is fine on ivory and marginal on the scrim, which is why the light theme carries a second, lifted value (
#8FA6FF) for chrome and eyebrows that sit over an image.brand_themechecks the accent against the ground and the card; it cannot check it against a photograph nobody has chosen yet, so render the hero slides and let the QA pass measure them. - Overriding
fontsinvalidates every number in the text budgets above. They are derived from Inter's and JetBrains Mono's measured advance widths. Another typeface has different widths, the assistant has to re-derive them by hand, and nothing in a workspace can render the result to check the answer. Expect to review the first deck yourself.
logo is a path relative to the workspace root. Confirm the file actually exists before referencing it. set_brand refuses a path that does not resolve, which is one error at the top of the script rather than a broken image frame on every slide — but a deck with no logo is correct, so leave the key out rather than guessing at a filename. fonts is written as heading / mono; body text follows whatever the heading font is, the same way Inter and JetBrains Mono pair in the default.
If a workspace has a deck-building skill installed and it's explicitly invoked, that skill wins over this page on look and content — the brand colours, the typeface, the tone, which slides to include. An explicit instruction always beats a default. It does not silently win on the toolchain: unless it names a different builder and says why, the deck is still built with /workspace/scripts/deck/plank_deck.py and still checked with /workspace/scripts/deck/plank_deck_qa.py. A skill that says nothing about how to build has not chosen another route; it never mentioned this one.
Checking the deck
The budgets above run before a slide is drawn. Some faults only exist once a renderer has laid the deck out — a table whose cells wrapped and grew its rows, two boxes that landed on each other, a chart whose labels collide, a picture that didn't decode. A Plank workspace can now render a .pptx and look at the result, so those are checkable too:
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck_qa.py https://plank.md/help/assets/plank_deck_qa.py
python3 /workspace/scripts/deck/plank_deck_qa.py quarterly-review.pptx
It converts the deck to PDF, rasterises every page to a PNG, reads back where each word actually landed, and compares that against the geometry the deck declares. Findings print per slide, saying what is wrong and what to change; it exits non-zero when anything is an error, so it drops straight into a build → check → repair loop. Re-run it after each repair.
It takes either format — plank_deck_qa.py deck.pptx or plank_deck_qa.py deck.html — and it is one tool, not two that agree. The formats differ in exactly two places: which reader pulls the authored geometry out (OOXML, or the .slides.json the HTML builder writes) and which renderer produces the PDF (LibreOffice, or headless Chromium). Every detector, threshold, finding and exit code below that is the same code running on both.
It also produces the PDF, in <deck>-qa/ next to the deck. That is a deliverable in its own right — a .pptx deck usually has to be handed over as both an editable file and a PDF that looks the same everywhere, and for an HTML deck the PDF is the deliverable.
What it detects. Text rendering outside the shape it belongs to; text outside the slide's safe margins; words landing on top of other words, including chart labels; shapes that partially overlap; slides that came out blank or rendered invisible; template text like Lorem ipsum, TBD or [fill this in]; pictures whose bytes don't decode or that rendered as an empty frame; text over a photograph that isn't legible against it; fonts the deck asks for that aren't installed, so the renderer quietly substituted something else; and more than two typefaces — a too-many-fonts warning counting every face the deck sets. It names no culprit: nothing in the file says which two were meant, and a brand override replaces both rather than adding a third.
The contrast check is the one that needs the render. Nothing in the .pptx knows whether a title is readable over its own picture: the file records that the text is #FAFAF7 and that a photograph is behind it, and says nothing about what colour that photograph happens to be at that spot. So for every word rendered over a picture the tool reads the pixels the renderer actually put behind it, sets aside the ones that are the glyph itself, and computes the WCAG contrast ratio against what's left. Under 3:1 is an error — nothing at display size survives less. Between 3:1 and 4.5:1 is a warning, because a title clears 3:1 but a lead line or a caption doesn't. The fix is never a bigger type size and never a different photograph; it's a scrim. See Images.
Russian typography is checked too — and the same tool checks a document. Most people reading a Plank deliverable read Russian, and "прямые кавычки" where «ёлочки» belong, a hyphen standing in for a dash, and ё left out make a deliverable read as foreign however good its structure is. None of that needs a reader; it is decidable from the bytes. So it is checked rather than suggested: «ёлочки» for Russian quotation marks, ё in words that have only one reading, an em dash between clauses and an en dash for ranges, thousands grouped the Russian way with a decimal comma, and a non-breaking space between a number and its unit in text long enough to wrap.
Because a Russian report or dashboard is a deliverable too, hand the same tool an .html, .md or .txt file and it proofreads the visible text with the same rules, the same report and the same exit status. Nothing is rendered in that mode, so it needs neither LibreOffice nor poppler:
python3 /workspace/scripts/deck/plank_deck_qa.py квартальный-отчёт.html
python3 /workspace/scripts/deck/plank_deck_qa.py дашборд.html
So .html names two jobs, and you don't have to say which. An HTML slide deck is rendered and measured; an HTML document or dashboard is proofread. The tool tells them apart by the .slides.json manifest plank_slides.py writes beside a deck: manifest means deck, none means document — so a deck is never quietly downgraded to a proofread that skips its geometry, and a deck whose manifest went missing fails loudly and asks you to rebuild rather than being read as prose. A deck gets both halves anyway: the Russian rules run on its slides too.
The rule list is short on purpose. Flagging correct Russian is worse than missing wrong Russian — a checker that cries wolf gets switched off, and then it catches nothing at all. So anything undecidable in isolation is left out: ё in words with a second reading (все/всё, чем/чём, узнаем/узнаём), a non-breaking space after a one-letter preposition, a missing space before %, a name quoted in «ёлочки» (1C spells its own document types «Счет на оплату покупателю», and that is not yours to correct), and anything inside a code block, a URL or a filename. A document that never writes ё anywhere is a house style, not a mistake, and gets one line in the header instead of a finding on every word.
What it still cannot check is the writing — mechanical AI vocabulary, verbal nouns that should be verbs, impersonal passives, empty evaluative adjectives, claims with nothing behind them. That half needs a reader, and it is what the Deliverable Writing skill is for.
What it does not. It has no opinion on whether the deck is any good — hierarchy, pacing, whether the chart is the right chart, whether a slide earns its place. It detects overflow, collision and blankness. It cannot detect ugly. A clean report means nothing is broken; it does not mean the deck is finished, which is what the critique pass below is for.
Findings are not yours to absorb silently. The tool grades what it finds: an ERROR is a broken slide, a WARNING is a defect that is probably still readable — a substituted font, a descender a point outside its box. That split is deliberate and is not going to be escalated; if every warning were an error, errors would stop meaning anything.
What the split does not license is dropping the warnings. A run that reports "12 slides, 3 clean, 9 with findings" has found nine defects, and "no rendering errors" is a false description of it even though zero of them were errors. Warnings exist so that you can judge them — repair it, or decide it is acceptable and say which. See Before handing it over for what has to reach the user.
One thing the numbers depend on, and it differs by format. For a .pptx the renderer is LibreOffice, not PowerPoint — line breaking differs slightly, so treat a finding within a point or two of tolerance as a near miss rather than a fact. For an HTML deck there is no such gap: Chromium is the engine the deck was designed against, so the widths in the report are the reader's widths.
An HTML slide clips at its own edge (otherwise slide 3's overrun paints onto slide 4's page), so text that left the slide entirely used to be absent from the report rather than reported — the one case where "no findings" could mean "nothing could see it". It is measured now: before printing, the tool asks Chromium where every block's words really landed, and the ones that fall outside the slide come back as ordinary overflow findings, named against the box they came from. The header line says how many blocks were measured and how many words were off the slide. A deck built by an older plank_slides.py carries no layout markers and cannot be measured — the report says that too, instead of reporting nothing.
The other thing they depended on used to be left to you: the deck's fonts have to actually be installed, or every width in the report was measured against whatever the renderer substituted. The tool now asks fontconfig and says so — a font-substituted warning naming each missing face, on the first slide that asks for it. A clean run means the measurements are against the fonts the deck asked for, not that nobody checked.
The critique pass
Everything above this point checks that the deck is well-formed. None of it has read the deck — see Writing the slides for what that misses.
So building a deck is four steps, not three, and the third is not optional:
build → mechanical QA → critique → revise → deliver
The critique runs on the finished file, after plank_deck_qa.py and before you hand anything over. The tool prints it at the end of every run — including a clean one, because a clean mechanical report is exactly when a deck gets shipped unread. Re-run it after you revise, the same way you re-run the QA.
The half that is mechanical
Two things come out of the tool, and both are cheap:
- The title ladder — every slide's title, in order, printed on its own. This is not a check. It is the titles-alone test made unavoidable: the tool already knows every title, so printing them costs nothing and removes the excuse for not reading them.
- A few pattern prompts — a title that matches a filler word (
Overview,Обзор,Наши преимущества,Спасибо); a title of three words or fewer with no number in it; the first slide after the cover naming a subject rather than stating the answer (answer-not-first); two slides whose titles share most of their words; a slide carrying a percentage or a multiplier with no source note in sight.
answer-not-first is the pyramid rule mechanised as far as it goes. The tool can see that the opening title is a subject; it cannot see that a well-formed opening title states the wrong answer. That half is judgement question 1.
The prompts are heuristics with real false-positive rates, so they print as questions, not verdicts. They are typed info, they never become errors, and they never change the exit status. Overruling one is a perfectly normal answer — overruling it silently is not.
The half that is judgement
These five cannot be automated, and the tool prints them without answering them. Answer all five, in words, in your reply:
- Titles alone. Read the ladder and nothing else. Does the first line already state the answer, or only promise one? Then: does the ladder carry the argument end to end? Name the first title that made you reach for the body to understand it — that one is a subject, not a finding.
- Does every slide earn its place? Name a slide that could be deleted with nothing lost, or say that none can and why. A slide repeating an earlier point counts as deletable.
- Is the "so what" stated? Quote the words on the deck that say what the reader should do, and say who owns it. Not implied, not "we should consider" — a decision with an owner, and a date where one applies. If you cannot quote it, this is a report, and a report does not need slides.
- What evidence is missing? Name every number with no source and every assertion with nothing behind it. "None" is valid only if you checked each one. A source that lives only in the speaker notes is worth saying out loud — the audience never sees those.
- Look at the rendered PNGs.
plank_deck_qa.pyrasterises every slide and prints their paths; pass those paths toplank_look_at(up to six per call) and you see the slides as pictures. That is the only way to see them —readon an image file returns base64 you cannot use. Then judge what geometry cannot: does each slide have one clear focal point, or do two things compete? Do slides of the same kind look the same? Does anything read as unfinished — a lonely block of text, a chart with no takeaway, a half-empty card? Is every picture real content rather than placeholder art, and every photograph the right way up? Does any colour contradict the number it sits on — a rise coloured as good when a rise is bad? None of that is measurable, and all of it is visible. Ask those questions by name: in testing, the same slides reviewed with "tell me about any visual problem" surfaced nothing, and the same images reviewed against this list caught both a placeholder image and a delta coloured green on a metric that had got worse. Naming nothing is a valid answer, and a better one than a manufactured finding. The images are downsampled before you see them, so judge layout, contrast and hierarchy rather than the smallest type.
Before handing it over
Before you consider a deck finished, check it against this list:
- No placeholder text anywhere — no "Lorem ipsum," no "TBD," no bracketed
[fill this in]. - Every text budget above is respected. A
DeckErrormeans split the slide, not shrink the font. - Every slide has speaker notes. The builder refuses to build a slide without them — it validates before it draws anything, so a rejected call adds no slide at all.
- Every chart has a takeaway title and a source note.
- Every image path resolves to a real file, and every slide carrying one was looked at in the rendered PNG with
plank_look_at— not inferred from the geometry report. - If a logo is in play, its path resolves to a real file.
- The theme is the one the deck should be in.
studiofor persuasion;reportfor anything that gets printed or scrutinised. - The file has a descriptive, content-specific name — never
deck.pptx. plank_deck_qa.pyreports no errors on it, and the PDF it produced is handed over alongside the.pptx. See Checking the deck.- The critique pass has been run on the final file, and its five judgement questions are answered.
What you have to tell the user
Handing over the files is not the whole delivery. Your reply has to state three things, and they do not stop being required just because every finding was a warning:
- What QA found — the count and the kinds. If that count is not zero, "no rendering errors" / «ошибок рендеринга нет» is not an honest description of the run, however many of the findings were warnings rather than errors.
- What you fixed — and that you re-ran
plank_deck_qa.pyafterwards to confirm it. - What you consciously left, and why — every finding you did not repair, named, with the reason it is acceptable. Silence is not a judgement, and a finding nobody mentioned is a finding nobody decided about.
Then the five judgement answers from the critique pass. A deck that has been measured but not read has not been checked — it has been rendered.