pptx · diff
git:20260826.8da358a to git:20260915.a4d1ed7
249 added, 159 removed. Audit A to A.
---
name: pptx
- description: "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill."
- license: Proprietary. LICENSE.txt has complete terms
+ description: "PowerPoint decks built with pptxgenjs and native charts, existing decks edited in place with python-pptx, then rendered to images and audited for overflow, overlap, bounds and typography before delivery"
---
- # PPTX Skill
+ # PPTX
- ## Quick Reference
+ Build or edit a PowerPoint deck and write it into the task directory (e.g. `work/acme_q4/acme_q4_review.pptx`). Someone will stand in front of it, or forward it to a colleague who will. That is what the format is for: **a deck is a sequence of single claims, each sized to be read from the back of a room.** A slide that needs a paragraph is not a slide, it is a page, and it belongs in a report.
- | Task | Guide |
- |------|-------|
- | Read/analyze content | `python -m markitdown presentation.pptx` |
- | Edit or create from template | Read [editing.md](editing.md) |
- | Create from scratch | Read [pptxgenjs.md](pptxgenjs.md) |
+ This is the right output when the deck itself is the deliverable: an investment committee pack, a diligence readout, a board update, an earnings walkthrough. It is the wrong output for anything the reader will study alone at their own pace (that is `html-report`) and for anything they will change numbers in (that is `xlsx`).
- ---
+ > **User preferences override these defaults.** A house template, a brand palette, a required title slide, a fixed section order: those outrank every rule here. If the user hands you a deck to work in, work in it and match what is already there. The rules below are for when nothing has been specified.
- ## Reading Content
+ ## Decide: Which Output?
- ```bash
- # Text extraction
- python -m markitdown presentation.pptx
+ | Want | Use |
+ |---|---|
+ | Something to present from, or hand to someone who will present it and edit slide by slide | **pptx** (this skill) |
+ | A document to read alone, print, or export to PDF | `html-report` |
+ | A model they will change inputs in | `xlsx` |
+ | One chart or table inside the conversation | `inline-widget` |
+ | A live app with a backend | `interactive-dashboard` |
- # Visual overview
- python scripts/thumbnail.py presentation.pptx
+ ## Workflow
- # Raw XML
- python scripts/office/unpack.py presentation.pptx unpacked/
- ```
+ 1. **Outline first, one message per slide.** Write the outline before any code, as `## Slide N` headings each carrying one declarative sentence: the claim the slide makes, not its topic. "Revenue grew 21 percent on flat headcount" is a slide. "Revenue" is a filing cabinet. If you cannot write the sentence, you do not have the slide yet.
+ 2. **Write a build script**, `work/<task>/build_<name>.js`, and run it with `NODE_PATH=$(npm root -g) node work/<task>/build_<name>.js`. Never assemble a deck through ad-hoc calls. The script is the source of truth; when the user asks for a change, edit the script and rerun.
+ 3. **Audit the file, not the script.** `python .agents/skills/pptx/scripts/check.py work/<task>/<name>.pptx --strict` reads what the file contains, including the position a placeholder inherits from its layout, and reports shapes off the slide, boxes covering each other, overflowing text, fonts and placeholders. It compares the slide's own shapes, so branding that lives on the layout or the master is something only the render in step 4 will show you covered. Fix every `fail`; for every `warn`, either fix it or write the one line in the delivery that says why it stands.
+ 4. **Render and look**: `python .agents/skills/pptx/scripts/render.py work/<task>/<name>.pptx --montage`, then view `montage.png`. This is not optional. Clipped text, a legend over a bar, a title that jumps, one slide twice as dense as its neighbours: all of it is visible here and nowhere else.
+ 5. **Fix in the build script, never in the .pptx**, then rerun steps 2 to 4 until `check.py` passes and the render looks right.
+ 6. **Deliver the `.pptx` and the `.js` beside it**, and say in the reply what each slide claims.
- ---
+ ## Design Rules
- ## Editing Workflow
+ **Geometry.** 16:9 (`LAYOUT_WIDE`, 13.333 x 7.5 in) unless the user's screen is not. The 0.6 in margin is the left and right content margin, so content spans 12.13 in; vertically each band sits at the `y` below, from the title at 0.45 to the footer band ending at 7.25. One geometry for every content slide:
- **Read [editing.md](editing.md) for full details.**
+ | Band | y | Height |
+ |---|---|---|
+ | Title | 0.45 | 0.60 |
+ | Kicker (units, period, scope) | 1.05 | 0.35 |
+ | Rule | 1.42 | 0.02 |
+ | Content | 1.75 | up to 4.85 |
+ | Footer and page number | 6.95 | 0.30 |
- 1. Analyze template with `thumbnail.py`
- 2. Unpack → manipulate slides → edit content → clean → pack
+ Write that as one function in the build script and call it for every slide. A title that moves by a tenth of an inch between slides reads as a flicker when the deck is clicked through, and `check.py` reports it.
- ---
+ **One idea per slide.** The title states the finding. The body supports it. If the body supports two findings, that is two slides. Three bullets is a good slide, six is a document, and nine is a filing.
- ## Creating from Scratch
+ **Type.** Two families at most, and one is usually enough. Sizes:
- **Read [pptxgenjs.md](pptxgenjs.md) for full details.**
+ | Role | Size | Weight |
+ |---|---|---|
+ | Cover title | 40pt | bold |
+ | Slide title | 28pt | bold |
+ | Card or section heading | 18pt | bold |
+ | Body and bullets | 16 to 18pt | regular |
+ | Table text | 14pt | header bold |
+ | Chart axis and data labels | 12pt | regular |
+ | Footer, source line, footnote | 10pt | regular, secondary colour |
- Use when no template or reference presentation is available.
+ Nothing below 10pt, ever, and nothing below 14pt in a table. A number small enough to need leaning in is a number nobody checks.
- ---
+ **Budgets `check.py` holds you to.** Its overflow estimate assumes an average glyph 0.50 em wide (0.54 bold) and 1.2 line spacing, so a box fits about `width_in * 72 / (pt * 0.5)` characters per line: a 28pt bold title across the 12.13 in content width is one line up to about 56 characters, and the 0.60 in title band holds exactly one. Write the finding in under 56 characters or drop to 24pt. The checker takes the largest text in the top 30 percent of the slide (2.25 in) as the title when there is no title placeholder, which is every pptxgenjs deck, so a 40pt number in that band is read as a wandering title: big numbers sit below 2.25 in. Page numbers are a 10pt `addText` box in the footer band, never `slide.slideNumber`, which ignores `fontSize` and renders at 18pt in a box too short for it.
- ## Design Ideas
+ **Fonts.** Arial, Calibri, Cambria, Times New Roman and Courier New each have a metric-compatible clone installed here (Liberation Sans, Carlito, Caladea, Liberation Serif, Liberation Mono), so the render you inspect breaks its lines exactly where PowerPoint will. Georgia, Verdana, Tahoma, Trebuchet MS and anything else do not: they render substituted, so the layout you checked is not the layout the reader gets. Set `fontFace` explicitly on every text element; a run with no font takes whatever the theme hands it.
- **Don't create boring slides.** Plain bullets on a white background won't impress anyone. Consider ideas from this list for each slide.
+ **Colour.** One accent, used for one meaning. Everything else is ink, grey, and paper.
- ### Before Starting
+ | Role | Hex |
+ |---|---|
+ | Slide ground | `FFFFFF` |
+ | Title and body ink | `1A1A1A` |
+ | Secondary text, labels, footers | `5A5A5A` |
+ | Rules, borders, gridlines | `D8D5D0` |
+ | Panel and card fill | `F4F2EE` |
+ | Accent, and chart series 1 | `1F4E79` |
+ | Chart series 2, 3 | `4E86B8`, `8FB4D4` |
+ | Positive, gain | `1A7F4F` |
+ | Negative, loss | `B42318` |
- - **Pick a bold, content-informed color palette**: The palette should feel designed for THIS topic. If swapping your colors into a completely different presentation would still "work," you haven't made specific enough choices.
- - **Dominance over equality**: One color should dominate (60-70% visual weight), with 1-2 supporting tones and one sharp accent. Never give all colors equal weight.
- - **Dark/light contrast**: Dark backgrounds for title + conclusion slides, light for content ("sandwich" structure). Or commit to dark throughout for a premium feel.
- - **Commit to a visual motif**: Pick ONE distinctive element and repeat it — rounded image frames, icons in colored circles, thick single-side borders. Carry it across every slide.
+ Green and red mean gain and loss and nothing else. Never encode a category in a colour that already means a direction.
- ### Color Palettes
+ **Speaker notes** when the user will present, or when they asked. One or two sentences per slide saying what to say, not a transcript of the slide. `check.py --require-notes` enforces the presence, not the quality.
- Choose colors that match your topic — don't default to generic blue. Use these palettes as inspiration:
+ ## Build a New Deck
- | Theme | Primary | Secondary | Accent |
- |-------|---------|-----------|--------|
- | **Midnight Executive** | `1E2761` (navy) | `CADCFC` (ice blue) | `FFFFFF` (white) |
- | **Forest & Moss** | `2C5F2D` (forest) | `97BC62` (moss) | `F5F5F5` (cream) |
- | **Coral Energy** | `F96167` (coral) | `F9E795` (gold) | `2F3C7E` (navy) |
- | **Warm Terracotta** | `B85042` (terracotta) | `E7E8D1` (sand) | `A7BEAE` (sage) |
- | **Ocean Gradient** | `065A82` (deep blue) | `1C7293` (teal) | `21295C` (midnight) |
- | **Charcoal Minimal** | `36454F` (charcoal) | `F2F2F2` (off-white) | `212121` (black) |
- | **Teal Trust** | `028090` (teal) | `00A896` (seafoam) | `02C39A` (mint) |
- | **Berry & Cream** | `6D2E46` (berry) | `A26769` (dusty rose) | `ECE2D0` (cream) |
- | **Sage Calm** | `84B59F` (sage) | `69A297` (eucalyptus) | `50808E` (slate) |
- | **Cherry Bold** | `990011` (cherry) | `FCF6F5` (off-white) | `2F3C7E` (navy) |
+ pptxgenjs 4.0.1 is installed globally, so the script needs `NODE_PATH=$(npm root -g)`.
- ### For Each Slide
+ ```js
+ const PptxGenJS = require("pptxgenjs");
- **Every slide needs a visual element** — image, chart, icon, or shape. Text-only slides are forgettable.
+ const INK = "1A1A1A", MUTED = "5A5A5A", RULE = "D8D5D0", ACCENT = "1F4E79";
+ const FONT = "Arial", M = 0.6, W = 13.333, CONTENT_W = W - 2 * M;
- **Layout options:**
- - Two-column (text left, illustration on right)
- - Icon + text rows (icon in colored circle, bold header, description below)
- - 2x2 or 2x3 grid (image on one side, grid of content blocks on other)
- - Half-bleed image (full left or right side) with content overlay
+ const pptx = new PptxGenJS();
+ pptx.layout = "LAYOUT_WIDE";
- **Data display:**
- - Large stat callouts (big numbers 60-72pt with small labels below)
- - Comparison columns (before/after, pros/cons, side-by-side options)
- - Timeline or process flow (numbered steps, arrows)
+ // One geometry for every content slide, so the title never moves.
+ function contentSlide(title, kicker) {
+ const slide = pptx.addSlide();
+ slide.background = { color: "FFFFFF" };
+ slide.addText(title, { x: M, y: 0.45, w: CONTENT_W, h: 0.6,
+ fontFace: FONT, fontSize: 28, bold: true, color: INK, valign: "middle" });
+ slide.addText(kicker, { x: M, y: 1.05, w: CONTENT_W, h: 0.35,
+ fontFace: FONT, fontSize: 14, color: MUTED, valign: "top" });
+ slide.addShape("rect", { x: M, y: 1.42, w: CONTENT_W, h: 0.02, fill: { color: RULE } });
+ return slide;
+ }
- **Visual polish:**
- - Icons in small colored circles next to section headers
- - Italic accent text for key stats or taglines
+ const s = contentSlide("Revenue grew 21 percent on flat headcount", "Three findings drive the FY2026 view");
+ s.addText(
+ [
+ { text: "Q4 revenue of $1,240mm beat consensus by 3.4 percent.", options: { bullet: true } },
+ { text: "Gross margin held at 64.2 percent on a heavier hardware mix.", options: { bullet: true } },
+ { text: "Net retention of 118 percent carries half the FY2026 plan.", options: { bullet: true } },
+ ],
+ { x: M, y: 1.9, w: CONTENT_W, h: 2.6, fontFace: FONT, fontSize: 18, color: INK,
+ lineSpacingMultiple: 1.4, valign: "top" }
+ );
+ s.addNotes("Do not read the bullets out; the room has already read them. Lead with the margin point.");
- ### Typography
+ pptx.writeFile({ fileName: "work/acme_q4/acme_q4_review.pptx" })
+ .then(() => console.log("written")); // the check runs on the file: see step 3
+ ```
- **Choose an interesting font pairing** — don't default to Arial. Pick a header font with personality and pair it with a clean body font.
+ Geometry is checked on the written file by `check.py`, which reads the real slide XML rather than the build script's intent. A shape crossing a slide edge, or two text boxes covering each other by more than 0.1 in in both directions, is a `fail`. Fix every one in the script and rebuild.
- | Header Font | Body Font |
- |-------------|-----------|
- | Georgia | Calibri |
- | Arial Black | Arial |
- | Calibri | Calibri Light |
- | Cambria | Calibri |
- | Trebuchet MS | Calibri |
- | Impact | Arial |
- | Palatino | Garamond |
- | Consolas | Calibri |
+ ### Charts stay native
- | Element | Size |
- |---------|------|
- | Slide title | 36-44pt bold |
- | Section header | 20-24pt bold |
- | Body text | 14-16pt |
- | Captions | 10-12pt muted |
+ `addChart` writes a real chart part that the reader can click into, retheme, and read numbers off. A picture of a chart is a dead end for them and blurs at any zoom.
- ### Spacing
+ ```js
+ slide.addChart(
+ pptx.ChartType.bar,
+ [{ name: "Revenue ($mm)", labels: ["Q1", "Q2", "Q3", "Q4"], values: [980, 1035, 1128, 1240] }],
+ { x: 0.6, y: 1.75, w: 7.4, h: 4.6,
+ barDir: "col", chartColors: [ACCENT], showLegend: false, showValue: true,
+ dataLabelFontFace: FONT, dataLabelFontSize: 12, dataLabelColor: INK,
+ catAxisLabelFontFace: FONT, catAxisLabelFontSize: 12, catAxisLabelColor: MUTED,
+ valAxisLabelFontFace: FONT, valAxisLabelFontSize: 12, valAxisLabelColor: MUTED,
+ valAxisMaxVal: 1400, valGridLine: { color: RULE, style: "solid", size: 1 },
+ catGridLine: { style: "none" }, valAxisLineShow: false, catAxisLineShow: false }
+ );
+ ```
- - 0.5" minimum margins
- - 0.3-0.5" between content blocks
- - Leave breathing room—don't fill every inch
+ - Series data is `[{ name, labels, values }]`, one object per series, `labels` identical across series.
+ - One series: pass **one** colour. `chartColors` with several entries colours each bar separately, which looks like four categories where there is one.
+ - Units go in the kicker or the axis title, not on every label. Turn the legend off when there is one series.
+ - `pptx.ChartType.bar` with `barDir: "col"` is vertical, `"bar"` is horizontal. `line`, `pie`, `doughnut`, `area`, `scatter` are the rest of the useful set.
+ - A combo chart (bars for one series, a line for another) is an array of `{ type, data, options }` in place of the single type, with the shared geometry and axis options in the second argument:
- ### Avoid (Common Mistakes)
+ ```js
+ slide.addChart(
+ [{ type: pptx.ChartType.bar, data: [{ name: "Revenue ($B)", labels, values: revenue }], options: { chartColors: [ACCENT], barDir: "col" } },
+ { type: pptx.ChartType.line, data: [{ name: "FCF ($B)", labels, values: fcf }], options: { chartColors: [SERIES2], lineSize: 3, lineDataSymbol: "circle" } }],
+ { x: 0.6, y: 1.75, w: 7.3, h: 4.7, showLegend: true, legendPos: "b", legendFontSize: 12,
+ catAxisLabelFontSize: 12, valAxisLabelFontSize: 12, valGridLine: { color: RULE, style: "solid", size: 1 } }
+ );
+ ```
+ - Reach for an image only when the chart type genuinely does not exist here (a waterfall, a slope chart, a small-multiple grid). Then say in the reply that it is an image.
- - **Don't repeat the same layout** — vary columns, cards, and callouts across slides
- - **Don't center body text** — left-align paragraphs and lists; center only titles
- - **Don't skimp on size contrast** — titles need 36pt+ to stand out from 14-16pt body
- - **Don't default to blue** — pick colors that reflect the specific topic
- - **Don't mix spacing randomly** — choose 0.3" or 0.5" gaps and use consistently
- - **Don't style one slide and leave the rest plain** — commit fully or keep it simple throughout
- - **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets
- - **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding
- - **Don't use low-contrast elements** — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds
- - **NEVER use accent lines under titles** — these are a hallmark of AI-generated slides; use whitespace or background color instead
+ Put the takeaway beside the chart in words. A chart with no sentence next to it makes the room do the work.
- ---
+ ### Tables
- ## QA (Required)
+ ```js
+ const head = { fill: { color: ACCENT }, color: "FFFFFF", bold: true };
+ slide.addTable(
+ [[{ text: "Segment", options: head }, { text: "Q4 revenue", options: head }],
+ ["Platform", "742"],
+ [{ text: "Total", options: { bold: true } }, { text: "1,240", options: { bold: true } }]],
+ { x: M, y: 1.9, w: CONTENT_W, colW: [4.6, 7.53], fontFace: FONT, fontSize: 14,
+ color: INK, rowH: 0.42, valign: "middle", border: { type: "solid", pt: 1, color: RULE } }
+ );
+ ```
- **Assume there are problems. Your job is to find them.**
+ A table is as tall as its rows: `rowH` times the row count, whatever `h` says, and that sum is what the geometry checks measure. Keep `y` plus the sum inside the content band.
- Your first render is almost never correct. Approach QA as a bug hunt, not a confirmation step. If you found zero issues on first inspection, you weren't looking hard enough.
+ Six rows and four columns is a slide. Twelve rows is a handout, so cut it to the rows that carry the argument and put the full table in an appendix slide or an xlsx. Right-align numbers, put units in the header, and state totals rather than implying them.
- ### Content QA
+ ## Editing an Existing Deck
- ```bash
- python -m markitdown output.pptx
- ```
+ **Never rebuild a human's deck to change three words.** Their master, layouts, theme, and every slide you are not touching are work you would be throwing away, and rebuilding loses the parts nobody documented. Open it with python-pptx and write into it.
- Check for missing content, typos, wrong order.
+ ```python
+ from pptx import Presentation
+ from pptx.util import Inches, Pt
- **When using templates, check for leftover placeholder text:**
+ prs = Presentation("work/acme_q4/client_deck.pptx")
- ```bash
- python -m markitdown output.pptx | grep -iE "xxxx|lorem|ipsum|this.*(page|slide).*layout"
- ```
+ def set_text(shape, new_text):
+ """Replace a shape's words and keep its formatting.
- If grep returns results, fix them before declaring success.
+ The first run carries the font, size and colour, so writing into it and
+ dropping the rest preserves the look without having to know what it is.
+ `shape.text = "..."` throws all of that away.
+ """
+ frame = shape.text_frame
+ paragraph = frame.paragraphs[0]
+ if not paragraph.runs:
+ paragraph.add_run()
+ paragraph.runs[0].text = new_text
+ for run in paragraph.runs[1:]:
+ run._r.getparent().remove(run._r)
+ for extra in frame.paragraphs[1:]:
+ extra._p.getparent().remove(extra._p)
- ### Visual QA
+ slide = prs.slides[3]
+ set_text(slide.shapes.title, "Segment detail, restated")
- **⚠️ USE SUBAGENTS** — even for 2-3 slides. You've been staring at the code and will see what you expect, not what's there. Subagents have fresh eyes.
+ # A new slide comes from a layout the deck already has, so it inherits the design.
+ layout = next(l for l in prs.slide_layouts if l.name == "Title and Content")
+ new = prs.slides.add_slide(layout)
+ new.shapes.title.text = "FY2026 bridge"
+ new.placeholders[1].text_frame.text = "Volume carries 14 of the 21 points."
- Convert slides to images (see [Converting to Images](#converting-to-images)), then use this prompt:
+ prs.save("work/acme_q4/client_deck_v2.pptx")
+ ```
+ Read the deck first: `python .agents/skills/pptx/scripts/extract.py deck.pptx` gives every slide's text, notes, tables, charts, and the shape names and coordinates you will write into. `markitdown deck.pptx` is faster for prose alone but drops every position, so it answers "what does this say" and never "which box do I write into". A legacy `.ppt` opens with `python -c "import anydoc,sys; print(anydoc.to_markdown(sys.argv[1]))" old.ppt` for the same read-only view (never pass `ocr="hosted"`, which uploads the file to an external service); to edit one, convert it with `soffice --headless --convert-to pptx old.ppt` and start from the `.pptx`.
+
+ **What a python-pptx save preserved when it was measured**, on one six-slide deck with a chart, a table and notes: the theme part came back byte for byte, the slide parts that were not edited came back with the same elements, attributes and order (differing only in the whitespace between tags), and the native chart kept its series values. Two parts were rewritten and both are harmless: `[Content_Types].xml` is rebuilt from the parts present, and a relationship target is normalised from an absolute to a relative path. That is a measurement of one deck, not a guarantee for yours, so prove it on your own edit:
+
+ ```bash
+ python3 -c "
+ import zipfile
+ a, b = zipfile.ZipFile('before.pptx'), zipfile.ZipFile('after.pptx')
+ print([n for n in sorted(set(a.namelist()) & set(b.namelist()))
+ if not n.endswith('/') and a.read(n) != b.read(n)])"
```
- Visually inspect these slides. Assume there are issues — find them.
- Look for:
- - Overlapping elements (text through shapes, lines through words, stacked elements)
- - Text overflow or cut off at edges/box boundaries
- - Decorative lines positioned for single-line text but title wrapped to two lines
- - Source citations or footers colliding with content above
- - Elements too close (< 0.3" gaps) or cards/sections nearly touching
- - Uneven gaps (large empty area in one place, cramped in another)
- - Insufficient margin from slide edges (< 0.5")
- - Columns or similar elements not aligned consistently
- - Low-contrast text (e.g., light gray text on cream-colored background)
- - Low-contrast icons (e.g., dark icons on dark backgrounds without a contrasting circle)
- - Text boxes too narrow causing excessive wrapping
- - Leftover placeholder content
+ Then render both with `render.py` and compare the slides you did not touch. That comparison is mandatory, not a formality: a part that survives the byte diff can still come out different once a renderer lays it out. Rules that follow:
- For each slide, list issues or areas of concern, even if minor.
+ - **Match what is there.** Read a neighbouring slide's fonts, sizes and colours from `extract.py` and reuse them. Do not restyle a deck the user did not ask you to restyle.
+ - **Placeholders inherit their position** from the layout. Reading `shape.left` gives the inherited value; writing to it pins the shape and ends the inheritance, so leave it alone unless moving it is the task.
+ - **A run's `font.size` of `None` means inherited**, not 18pt. Do not "fix" it by setting a size.
+ - **There is no copy-a-slide.** Add a slide from the same layout and re-add the content. There is no delete either; remove the `sldId` from `prs.slides._sldIdLst` when you must.
+ - **Chart data**: `chart.replace_data(CategoryChartData(...))` refreshes the numbers and keeps the formatting. Deleting and re-adding the chart loses it.
+ - Rerun `check.py` and `render.py` on the edited file. An edit is a deck change like any other.
- Read and analyze these images:
- 1. /path/to/slide-01.jpg (Expected: [brief description])
- 2. /path/to/slide-02.jpg (Expected: [brief description])
+ ## Verification Scripts
- Report ALL issues found, including minor ones.
+ All three live under `.agents/skills/pptx/scripts/`, take a `.pptx` path (or `--help`), and print JSON to stdout. They read the saved file, so they judge a deck the same way whoever built it: yours, or the one the user sent you.
+
+ **`render.py <deck.pptx> [--out DIR] [--dpi N] [--montage] [--cols N] [--keep-pdf]`**: LibreOffice to PDF, pdftoppm to PNG, one image per slide, plus a numbered contact sheet with `--montage`. A flag outside those five is an error, so a misspelt `--montage` stops the run instead of rendering the deck without the contact sheet and reporting success; `--dpi` and `--cols` take whole numbers, 1 to 600 and 1 to 12.
+
+ ```json
+ {"status": "success", "out": "...", "dpi": 110, "pages_rendered": 6, "slides_in_deck": 6,
+ "pages": ["...slide-1.png"], "montage": "...montage.png"}
```
- ### Verification Loop
+ `status` is `success` or `error` (no PDF, no images, no contact sheet), and only `error` exits non-zero; an `error` carries `previous_render_kept`, since the whole set is drawn beside `DIR` and swapped in only once every part of it exists, the contact sheet included. A `warning` field appears when the page count does not match the slide count, and `slide_of_page` says which deck slide each image shows: hidden slides, which LibreOffice leaves out of the PDF, are listed in `hidden_slides` and the images and montage tiles are then numbered by deck slide rather than by page, while anything else it dropped or split leaves `slide_of_page` null, the mapping being unknown. **Look at the montage.** A script cannot tell you the deck is ugly.
- 1. Generate slides → Convert to images → Inspect
- 2. **List issues found** (if none found, look again more critically)
- 3. Fix issues
- 4. **Re-verify affected slides** — one fix often creates another problem
- 5. Repeat until a full pass reveals no new issues
+ **`check.py <deck.pptx> [--strict] [--require-notes] [--slide N]`**: the audit. `--slide` counts from 1 and errors when the deck has no such slide, rather than passing an audit of nothing. A flag outside those three is an error too, so a misspelt `--strict` stops the run instead of dropping the gate and exiting 0.
- **Do not declare success until you've completed at least one fix-and-verify cycle.**
+ ```json
+ {"status": "fail", "stats": {"slides": 6, "chart_parts": 1, "images": 0, "tables": 1,
+ "pictures": 0, "notes_slides": 6, "aspect": 1.778, "fonts_used": ["Arial"],
+ "fonts_theme": {"major": "Calibri Light", "minor": "Calibri"}},
+ "findings": [{"check": "bounds", "level": "fail", "count": 2,
+ "message": "shape crosses a slide edge; part of it will never be seen",
+ "examples": ["slide 2 (Shape 1: right 15.00 > 13.33)"]}]}
+ ```
- ---
+ `status` is `pass` or `fail`. `fail`-level findings block delivery, `warn` is a judgement call, `info` is context. Only `--strict` makes a failing deck exit non-zero, so put `--strict` in the loop and let it stop you. `fonts_theme` is the first master's faces, and a deck carrying more than one master adds `fonts_theme_by_master`, since each slide resolves its fonts against the theme of its own master.
- ## Converting to Images
+ | Check | Level | What it means |
+ |---|---|---|
+ | `package`, `package_slides`, `package_media` | fail / warn | corrupt zip, slide parts that do not match the slide list, media nothing references |
+ | `bounds` | fail | a shape crosses a slide edge; a table is measured by the sum of its row heights |
+ | `overlap`, `overlap_shapes` | fail / warn | two shapes cover 15 percent of the smaller one, at least 0.1 in in both directions; containment is exempt only when the outer shape holds no text and the slide paints it first (a card, a full-slide background), and is reported when it holds text |
+ | `occluded` | fail | a filled shape fully covers text that sits earlier in the shape tree, so the render shows the fill and none of the words; a shape with no fill of its own is exempt, so is one whose fill the author turned down to 100% transparency, and a group is judged by its members, since they are what paint |
+ | `text_overflow`, `text_tight` | fail / warn | estimated line count needs more height than the box has |
+ | `placeholder` | fail | `Click to add`, `Lorem`, `TODO` or `[INSERT` still in the deck |
+ | `font_size`, `font_size_table` | fail | body under 10pt, table text under 14pt; a run that states no size of its own is judged on the size it inherits, resolved through its paragraph, its shape's list style, its layout placeholder, its master placeholder and the master's text styles; a run in a body PowerPoint autofitted is then judged at the size it draws, the stated or inherited size times the `fontScale` recorded on `a:normAutofit`, which the finding names as `32pt scaled to 20pt by autofit` |
+ | `fonts`, `fonts_metric` | fail / warn | more than two families; a family with no metric-compatible clone |
+ | `title_drift` | warn | a title away from the position the rest of the deck uses; the title is the placeholder or, without one, the largest text in the top 30 percent of the slide; slide 1 is exempt as the cover, whether or not it carries a title of its own |
+ | `aspect` | warn | not 16:9 |
+ | `notes` | fail | with `--require-notes`, a content slide with none |
+ | `charts_as_pictures` | warn | a large bitmap, or one named like a chart, where a native chart belongs |
+ | `font_size_inherited`, `geometry_unknown`, `hidden_shapes` | info | a run whose size nothing in that chain states and a shape with no position of its own, so the rule could not be applied; and the shapes the file marks hidden, named once and left out of every pass since PowerPoint draws none of them, a hidden group taking its members with it |
- Convert presentations to individual slide images for visual inspection:
+ Overflow is an estimate from font size, box width, character count and the line breaks inside a paragraph, not a measurement: it is deliberately loose and it reports lines, so read a finding as "go look at that slide in the render". An autofitted body is estimated at the size it draws, with its leading tightened by the `lnSpcReduction` recorded alongside the `fontScale`, since PowerPoint shrinks that text rather than letting it spill. Geometry checks reach inside a group, measuring each member where the group's own scale puts it and giving it the group's place in the paint order, and they measure a rotated shape by the box its spun corners cover rather than the rectangle stored in the file, a rotated group included: its angle spins its members about the group's centre, so a member is measured in the group's unrotated space, turned about that centre, and swept through the angles it and its groups add up to, a group inside a group included. Text inside a group is still read for the content checks and measured for overflow in the box the group's scale gives it rather than the box stored on the child, since resizing a group rescales that box and leaves the point size alone, so an 18pt run inside a group at half scale still renders at 18pt.
- ```bash
- python scripts/office/soffice.py --headless --convert-to pdf output.pptx
- pdftoppm -jpeg -r 150 output.pdf slide
- ```
+ **`extract.py <deck.pptx> [--slide N] [--text-only]`**: the deck as data, for reading before editing. Per slide: `title` and `title_from` (`placeholder`, or `largest_in_top_band` when the deck has no title placeholders, which is every pptxgenjs deck; that is the rule `check.py` uses too, and `topmost_text` is the last resort when nothing sits in the top band), `layout`, `text` (including table rows), `notes`, a `shapes` inventory with names, kinds, inches and font sizes (a table reports `height` as the extent the slide shows, with `frame_height` and `rows_height` beside it so you can see the two disagree), plus `tables` (cells) and `charts` (type, categories, series values; an XY or bubble series reports `x_values`, `y_values` and, for bubbles, `bubble_sizes` in place of `values`). A chart's cached points are optional in the format, and one that carries only its workbook references reports those instead, as `values_ref` on the series and `categories_ref` on the chart, rather than an empty list that reads as a chart with no numbers. A plot type python-pptx does not model, a 3-D pie or a stock chart, is named from the element in the part and carries `series_unread`: one such chart used to raise through the run and cost the inventory of every other slide in the deck. Both reach inside groups, in document order: a grouped chart is not in the slide's own shape list, so its numbers would otherwise reach neither the report nor `check-deck`. A member's `left`, `top`, `width` and `height` are slide inches like every other shape's, not the coordinate space its group stores them in, so they line up with the slide and with `check.py`'s findings; moving or resizing a group rewrites the group alone and leaves the stored numbers on every child untouched. Rotation is reported as the angle and never folded into the frame: a rotated shape or group lists its unrotated box, the numbers an edit assigns back, and `check.py` is where the turned box is measured. A shape the file marks hidden is left out of `text`, `tables` and `charts`, the way `check.py` leaves it out of every pass, so the report reads as the slide shows: a stale figure parked behind the live one is not a second claim about it and a hidden `Source:` line cites nothing. The `shapes` inventory still lists it, carrying `"hidden": true`, a hidden group taking its members with it, since an edit may be what unhides it. `--slide` counts from 1 and errors when the deck has no such slide, rather than returning an empty `slides`. A flag outside those two is an error too, so a misspelt `--slide` stops the run instead of returning the whole deck as though it had never been typed.
- This creates `slide-01.jpg`, `slide-02.jpg`, etc.
+ ## Pitfalls
- To re-render specific slides after fixes:
+ **pptxgenjs**
- ```bash
- pdftoppm -jpeg -r 150 -f N -l N output.pdf slide-fixed
- ```
+ - **Units are inches**, and pptxgenjs reads any number of 100 or more as EMUs instead. Keep coordinates in inches and never let a computed value cross 100.
+ - **Percentage strings** (`x: "50%"`) resolve against the slide at render time, so a mix of inches and percentages in one layout is a layout you cannot reason about. Pick inches.
+ - **`autoPage` is off by default**, so a table taller than the slide runs off the bottom silently. `check.py` takes a table's bounds from the sum of its row heights, which is what PowerPoint draws rather than the frame height stored in the file, and report it out of bounds. Cut the table, or set `autoPage: true` and accept that pptxgenjs picks the break points and the extra slides skip your title helper.
+ - **Never use `fit: "shrink"` or `autoFit`.** pptxgenjs writes `<a:normAutofit/>` with no `fontScale`, so LibreOffice shrinks the text to fit and PowerPoint does not: the render passes and the reader sees the overflow. Size the box to the text instead.
+ - **Text is vertically centred by default**, so an overfull box spills equally above and below and can cover the title. Set `valign: "top"` on every content box.
+ - **Rich text is an array of runs**: `[{ text, options }]`. `breakLine: true` ends a paragraph, `bullet: true` makes a bullet. A plain string is a single run and takes the box's options.
+ - **Bullets come from `{ bullet: true }`**, never a literal `•` character, which cannot be restyled and breaks indentation.
+ - **Images**: `sizing: { type: "contain" | "cover" | "crop", w, h }` preserves the aspect ratio. Setting `w` and `h` alone stretches the picture.
+ - **Colours are `RRGGBB` with no `#`.** `"#1F4E79"` is silently wrong.
+ - **`writeFile` returns a promise.** Do the work that follows inside `.then()` or the process can exit before the file lands.
+ - **`LAYOUT_WIDE` is 13.333 x 7.5 in, `LAYOUT_16x9` is 10 x 5.625 in.** Same ratio, different coordinate system. Choose once, at the top.
- ---
+ **python-pptx**
- ## Dependencies
+ - `shape.text = "..."` and `cell.text = "..."` destroy every run's formatting. Use the `set_text` helper above.
+ - `shape.left` and friends are EMUs. `Inches(1.5)` to write, `shape.left.inches` to read.
+ - Table cells: `cell.text_frame.paragraphs[0].runs[0]`, same rules as any other text frame.
+ - Do not open a deck with python-pptx to *build* one from nothing. Its default template is 4:3 with placeholders you will fight; pptxgenjs is the build path.
+ - Charts added by python-pptx need `CategoryChartData`; the pptxgenjs data shape is not the same.
- - `pip install "markitdown[pptx]"` - text extraction
- - `pip install Pillow` - thumbnail grids
- - `npm install -g pptxgenjs` - creating from scratch
- - LibreOffice (`soffice`) - PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`)
- - Poppler (`pdftoppm`) - PDF to images
+ ## Deliverable Checklist
+
+ - Every slide's claim is stated in its title, and the outline sentence and the title still match.
+ - `check.py --strict` passes.
+ - The montage was rendered and looked at, and every slide was worth looking at twice.
+ - 16:9, one geometry for titles, nothing outside the 0.6 in side margins or below the footer band.
+ - At most two font families, all metric-safe; nothing under 10pt, nothing under 14pt in a table.
+ - Numeric charts are native chart parts (`stats.chart_parts` is not zero when the deck has charts), one colour per series, units stated once.
+ - Speaker notes where the user will present.
+ - No `Click to add`, no `TODO`, no `Lorem`, no source line missing from a slide that quotes a number.
+ - An edited deck was diffed against the original and only the parts you meant to change moved.
+ - The build script sits next to the deck and reruns cleanly, and the reply names the file and what each slide claims.