references/python-pptx-notes.md
# python-pptx notes for template-bound decks
Checked against the python-pptx docs (https://python-pptx.readthedocs.io, "Understanding placeholders", "Working with placeholders", "Charts", the text auto-fit analysis pages) and against python-pptx 1.0.2 behavior in this skill's tests. Items marked *(observed)* were verified by running code, not taken from the docs.
## Units
- EMU (English Metric Unit): 914,400 per inch, 12,700 per point, 360,000 per cm. Use `pptx.util.Inches/Pt/Emu/Cm`.
- A 16:9 slide is 12,192,000 x 6,858,000 EMU (13.333 x 7.5 in). A 4:3 slide is 9,144,000 x 6,858,000 (10 x 7.5 in). Always read `prs.slide_width/height` and never assume.
- Font sizes in XML are hundredths of a point (`sz="1800"` = 18pt). Spacing: `spcPts val="600"` = 6pt, `spcPct val="20000"` = 20% of a line.
## Masters, layouts, placeholders
- Inheritance, from the docs: "A layout placeholder inherits from the master placeholder sharing the same type. A slide placeholder inherits from the layout placeholder having the same *idx* value." Position, size, fill, line and font all inherit.
- Access placeholders by idx, not by position: "Item access on the placeholders collection is like that of a dictionary rather than a list." `slide.placeholders[1]` means idx 1.
- `prs.slides.add_slide(layout)` clones the layout's placeholders **except date, footer and slide number** *(observed)*. Footers come from the master/layout only when the header-footer settings enable them. Do not fake them with text boxes.
- A placeholder with no `<a:xfrm>` of its own inherits its geometry. Reading `.left` returns the inherited value. **Setting `.left` alone creates a new xfrm with y=0 and height=0** *(observed)*: always set all four (left, top, width, height), or none.
- Placeholder types that matter: TITLE / CENTER_TITLE (title), SUBTITLE, BODY (text only), OBJECT (generic content: text, chart, table or picture), CHART, TABLE, PICTURE, plus DATE / FOOTER / SLIDE_NUMBER chrome. A placeholder with no `type` attribute is OBJECT.
- Only picture, table and chart placeholders have insertion methods (`insert_picture`, `insert_table`, `insert_chart`). "A reference to a ... placeholder becomes invalid after its insert_... method is called." Record the idx before inserting. The new shape can be fetched with `slide.placeholders[idx]`.
- To put a chart or table into an OBJECT placeholder, the way PowerPoint itself does it, wrap the element: `ChartPlaceholder(ph._element, ph._parent).insert_chart(...)` or `TablePlaceholder(...).insert_table(rows, cols)`. The result is a graphicFrame with `<p:ph idx=.../>`, positioned at the placeholder. `build_deck.py` does this. For a text-only BODY placeholder, it copies the geometry and removes the placeholder.
- Empty placeholders show "Click to add text" in edit view only. They are invisible in slideshow and PDF, which is why they slip through. Remove them: `ph._element.getparent().remove(ph._element)`.
- `SlideLayouts.remove(layout)` raises ValueError if any slide uses the layout. `layout.used_by_slides` tells you which ones do.
## Slides
- No API deletes a slide. Drop the relationship and the id: `prs.part.drop_rel(sldId.rId); prs.slides._sldIdLst.remove(sldId)`. The orphaned part is not written on save.
- Reorder by moving `sldId` elements within `prs.slides._sldIdLst`.
- Copying a slide between presentations is not supported. Deep-copying shapes loses rels (images, charts). Rebuild from the target deck's own layouts instead.
- Speaker notes: `slide.notes_slide.notes_text_frame.text = "..."` creates the notes slide from the notes master on first access.
## Opening .potx
python-pptx rejects a .potx because its main part content type is `presentationml.template.main+xml`. Rewrite `[Content_Types].xml` to `presentationml.presentation.main+xml` in memory and open that (`tdb_common.open_presentation`). Save the output as .pptx.
## Text and autofit (the overflow trap)
- `text_frame.auto_size`: `MSO_AUTO_SIZE.NONE`, `SHAPE_TO_FIT_TEXT` (`<a:spAutoFit/>`, the shape grows), `TEXT_TO_FIT_SHAPE` (`normAutofit`, shrink on overflow).
- Setting `TEXT_TO_FIT_SHAPE` writes `<a:normAutofit/>` but python-pptx does **not** compute the shrink. The docs note that PowerPoint writes a calculated `fontScale` and that "Later edits to that text using PowerPoint will re-fit the text automatically." PowerPoint generally shows such text at full size, overflowing, until someone edits it. LibreOffice applies its own shrink at render time *(observed)*, so a PDF can look fine while the .pptx does not.
- `text_frame.fit_text(font_family, max_size, bold, italic, font_file)` needs a TrueType file, sets `auto_size = NONE`, and writes one fixed size into every run. That breaks the template's type scale. Prefer cutting words.
- Many corporate masters set `normAutofit` on title and body. Treat it as a safety net, not a layout tool. `qa_deck.py` estimates fit at the nominal size and reports reliance on autofit.
- Text frame defaults: insets of 0.1in left/right and 0.05in top/bottom, word wrap on for placeholders. A new text box from `add_textbox` gets `wrap="none"` and `<a:spAutoFit/>` *(observed)*: set `word_wrap = True` or a long line runs off the slide.
- Setting `run.font.size` or `run.font.name` writes run-level overrides that beat the template. Only set them when the spec requires it (tables, source lines). Never set typefaces: the theme's `+mj-lt` / `+mn-lt` references keep the deck re-themeable.
- `paragraph.level` (0-8) selects the master's lvlNpPr bullet style. Use levels, not manual indents or typed bullet characters.
## Charts
- `CategoryChartData(number_format=...)`, `.categories = [...]`, `.add_series(name, values)`. Use `XyChartData` / `BubbleChartData` for scatter and bubble.
- The docs: "By default, the colors assigned to each series in a chart are the theme colors Accent 1 through Accent 6, in that order." Set colors explicitly with `fill.fore_color.theme_color = MSO_THEME_COLOR.ACCENT_n` (and `.brightness` for tints). Never use `.rgb`, which hard-codes a color that survives a re-theme. QA flags any srgbClr outside the theme palette.
- Per-point color: `series.points[i].format.fill`. It highlights one bar.
- `chart.font.size` sets the default text size for the whole chart. Data labels: `plot.has_data_labels = True`, `plot.data_labels.number_format = '0%'`, `number_format_is_linked = False` (otherwise the source format wins).
- `value_axis.has_major_gridlines = False`, `value_axis.visible = False` when labels carry the values. For bar charts, `category_axis.reverse_order = True` so the first category is on top.
- `chart.replace_data(chart_data)` updates an existing chart in place and keeps its formatting. Use it when editing decks.
- The chart workbook is embedded. The numbers are editable in PowerPoint, which is an advantage over pasting images.
## Tables
- `insert_table(rows, cols)` / `shapes.add_table(...)` sizes the frame to the placeholder and divides the height evenly across rows. The row height is a minimum: rows grow with wrapped text when rendered, so a long table can run off the slide even though its frame fits. `qa_deck.py` estimates the grown height.
- The default table style is `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}` (Medium Style 2 - Accent 1) *(observed)*, which is theme-driven. `table.first_row = True` styles the header. Leave the style in place and change only what the message needs (bold key row, right-aligned numbers).
- Merge cells with `cell.merge(other_cell)`. Set column widths through `table.columns[i].width`, and make them sum to the frame width.
## Common breakages and their fixes
| Symptom | Cause | Fix |
|---|---|---|
| Deck looks right but "does not use the template" | Built on Blank with text boxes | Use the layouts from `layout_map.json` |
| Title jumps position slide to slide | Placeholder xfrm overridden | Remove the slide-level `<a:xfrm>` so it inherits |
| Chart colors ignore the brand | `.rgb` fills, or a chart pasted as an image | Theme colors, native chart |
| Text fine in PDF, overflowing in PowerPoint | `normAutofit` without fontScale | Cut text, then re-check with QA |
| "Click to add text" in the client's edit view | Empty placeholders left behind | Remove unused placeholders |
| Wrong fonts after sending | Run-level `latin typeface` overrides | Remove them, inherit `+mn-lt` / `+mj-lt` |
| Footer and slide number missing | python-pptx does not clone them | Enable them in the template's header/footer settings |
| Zero-height placeholder at the top of the slide | Set `.left` on an inheriting placeholder | Set all four geometry values |
| PPTX will not open after an XML edit | Child elements out of schema order (for example `buNone` after `defRPr`) | Insert elements in the CT_ order, or rebuild the element |
references/storyboard-schema.md
# Storyboard schema (build_deck.py input)
One JSON file per deck. `build_deck.py` fills the template's placeholders from it. `qa_deck.py --storyboard` uses it to check the slide count and to recognize framing slides (title, section, quote), which are exempt from action-title rules.
## Top level
| Field | Meaning |
|---|---|
| `governing_thought` | The single-sentence answer the deck argues. Not rendered. Keep it here so the storyline stays honest. |
| `template` | Path to the .pptx/.potx. `--template` on the command line overrides it. |
| `slides` | Ordered list of slide specs (below). |
| `keep_template_slides` | Default `false`: sample slides in the template are removed before building. |
| `delete` | Edit mode: 1-based numbers of original slides to remove. |
| `order` | Final order as 1-based positions of the resulting slides, e.g. `[1, 3, 2, 4]`. |
| `expected_slide_count` | For QA in edit mode, where `slides` lists only the changes. |
## Slide spec
| Field | Meaning |
|---|---|
| `layout` | Exact layout name from `layout_map.json` (case-insensitive match allowed). |
| `type` | Used when `layout` is omitted: `title`, `section`, `agenda`, `one_message_chart` (alias `chart`), `two_column`, `table`, `quote`, `title_only`. The inspector's recommended layout is used. |
| `title` | The action title. It goes into the TITLE / CENTER_TITLE placeholder. |
| `subtitle` | Into SUBTITLE, or the first body placeholder (for example on a section divider). |
| `bullets` / `body` | String (`\n` splits paragraphs) or list. A nested list is one level deeper. `{"text": "...", "level": 2}` is also accepted. `**x**` makes a bold run. |
| `chart` | See below. Goes into a CHART or content (OBJECT) placeholder. |
| `table` | See below. Goes into a TABLE or content placeholder. |
| `columns` | List of `{heading?, bullets? / chart? / table?}`, mapped left to right onto the layout's content placeholders. If a column has a separate heading placeholder above its body, the heading goes there. Otherwise it becomes a bold first line. |
| `quote`, `attribution` | Largest body placeholder, then the next one. Curly quotes are added. |
| `source` | Into a placeholder named Source/Footnote, or a small body placeholder near the bottom. With neither, a text box `tdb-source` is added bottom-left and noted in the log. |
| `notes` | Speaker notes. |
| `placeholders` | Explicit fills by idx, e.g. `{"13": "Prepared for the board", "14": {"chart": {...}}}`. Use it for template-specific extras (date line, presenter, kicker). |
| `free_text` | List of `{text, left_in, top_in, width_in, height_in, size_pt?}`. Only when the user asks. QA flags every one. |
| `keep_empty_placeholders` | Default `false`: unused empty placeholders are removed. |
| `edit` | Edit mode: the 1-based number of the existing slide to change in place, instead of adding one. |
## Chart
```json
{"type": "bar", "categories": ["Top 12 lanes", "Next 28", "Remaining 100"],
"series": [{"name": "Share of loss", "values": [0.80, 0.15, 0.05]}],
"number_format": "0%", "highlight": 0}
```
- `type`: `column`, `bar`, `stacked_column`, `stacked_column_100`, `stacked_bar`, `line`, `line_plain`, `pie`, `doughnut`, `area`.
- `number_format`: an Excel format (`0%`, `0.0`, `$#,##0`, `#,##0,"K"`). It applies to both the axis and the data labels. `label_format` overrides the labels only.
- `highlight`: an index, a category name, or a list of either. Single-series bar/column only. The highlighted points get accent 1 and the rest a muted theme neutral.
- `data_labels` (default `true`) hides the value axis unless `hide_value_axis: false`. Gridlines are always off.
- Optional: `title` (usually omit it, since the slide title carries the message), `value_axis_title`, `font_size` (default 12), `gap_width` (default 60).
- Series colors are theme accents 1-6 in order, never RGB.
- Bar charts reverse the category axis so the first category reads on top.
## Table
```json
{"columns": ["Wave", "Lanes", "Price change", "Margin impact"],
"rows": [["Wave 1", "6", "+12%", "+1.8 pts"], ["Total", "12", "+10.5%", "+3.0 pts"]],
"col_widths": [2, 1, 1.4, 1.6], "highlight_row": 1}
```
- The table fills the placeholder's width. `col_widths` are relative ratios. Without them, widths follow the content length.
- The font size steps down with the row count (14/12/11/10pt) unless `font_size` is set. Numeric-looking cells are right-aligned.
- The header row is styled by the template's table style. `highlight_row` (0-based, data rows) is set in bold.
- If there are more than about 10 rows, the table belongs in the appendix or should be split. QA estimates the grown height and flags a table that runs off the slide.
## Minimal complete example
```json
{
"governing_thought": "Repricing twelve lanes recovers three margin points without layoffs.",
"slides": [
{"type": "title", "title": "Recovering three points of margin", "subtitle": "Board discussion | Sept 2026"},
{"layout": "Title and Content",
"title": "Repricing twelve loss-making lanes recovers three margin points without cutting headcount",
"bullets": ["**Situation:** margin fell from 7.8% to 4.9%", "**Complication:** 12 of 140 lanes drive 80% of the loss",
"**Resolution:** reprice them in two waves"],
"source": "Source: lane P&L FY2024-26", "notes": "The whole deck on one page."}
]
}
```
See `tests/storyboard_good.json` for a full six-slide deck and `tests/storyboard_bad.json` for what QA must catch.
references/storyline.md
# Storyline: making a deck argue something
A consulting deck is an argument, and each slide is one step in it. The failure to avoid is a deck that reads as a sequence of topics ("Market overview", "Financials", "Next steps") with no claim. The reader then has to work out the point, and usually works out a different one.
## 1. Start from the governing thought
Write one sentence that answers the audience's question before writing any slide. It should be specific, arguable and actionable.
- Weak: "An overview of our pricing situation."
- Strong: "Repricing twelve loss-making lanes recovers three margin points in 18 months without cutting headcount."
If you cannot write it, the analysis is not done yet, and more slides will not fix that. Ask the user what they want the audience to decide or believe.
## 2. Pyramid principle (Minto)
Put the answer first, then the three to five reasons that support it, then the evidence under each reason.
- Top: the governing thought (exec summary slide, and the title of the deck).
- Middle: key lines. Each one is an assertion that supports the top, usually a section of the deck.
- Bottom: evidence slides. Each proves one key line with one exhibit.
Checks:
- **Vertical:** every slide answers the question "why?" or "how?" raised by the level above it.
- **Horizontal:** key lines are MECE (mutually exclusive, collectively exhaustive) and all of the same kind: all reasons, or all steps, or all options. Do not mix.
- Group in threes where you honestly can. Seven key lines means the grouping is not done.
## 3. SCR for the opening
Use Situation, Complication, Resolution for the executive summary and for any slide that has to reset context.
- **Situation:** what the audience already knows and agrees with. ("Margin fell from 7.8% to 4.9% while volume grew 11%.")
- **Complication:** what changed or what is wrong, which creates the question. ("Twelve of 140 lanes generate 80% of the loss.")
- **Resolution:** the answer, which is the governing thought. ("Reprice those lanes in two waves.")
For a decision meeting, end the exec summary with the ask: "Decision today: approve Wave 1."
## 4. Action titles
The title is the takeaway of the slide, written as a full sentence.
| Topic label (bad) | Action title (good) |
|---|---|
| Lane profitability | Twelve of 140 lanes generate 80% of the operating loss |
| Competitive pricing | Competitors already charge 9-14% more on these lanes, so the increase is defensible |
| Rollout plan | A two-wave rollout captures 60% of the upside in the first two quarters |
Rules:
- A full sentence with a verb and a so-what. About 15 words or fewer, and no more than two lines in the template's title box.
- Quantify where possible ("80%", "three points", "18 months").
- Make one claim. If it needs "and", split the slide.
- The body proves the title and nothing else. Content that does not support the title moves to another slide, the notes, or the appendix.
- Do not put a question in the title. Answer it.
- Title slides, section dividers and quote slides are framing, and they are exempt.
## 5. Ghost deck (title test)
Before building, list the titles alone, numbered. That list is the ghost deck. Read it aloud, top to bottom:
- Does it make the whole argument without the bodies?
- Does each title follow from the one before ("so", "because", "therefore")?
- Would a board member who reads only the titles reach the same decision?
`qa_deck.py` prints this list as "Storyline" on every run. Re-read it after each fix loop. Titles drift when bodies get edited.
## 6. One message per slide
- One exhibit per slide, or two when they make one comparison.
- At most six bullets, in parallel grammar. Use `**Lead-in:** detail` so the slide can be skimmed.
- Put the detail the presenter will say in the speaker notes, not on the slide.
- Every number carries a unit and a period. Every exhibit carries a source line: `Source: <system/report>, <period>; <method note>`.
## 7. Chart choice follows the message
| Message is about | Use | Avoid |
|---|---|---|
| Ranking / comparison across items | Horizontal bar, sorted, with the key item highlighted | Pie with more than 3 slices |
| Change over time (few points) | Column | Line with 3 points |
| Trend over time (many points) | Line, with at most 4 series labeled directly | Legend-only lines |
| Part of a whole (2-3 parts) | Stacked bar/column, or a pie if really only 2-3 parts | Doughnut with 8 slices |
| Composition shift over time | 100% stacked column | Several pies |
| Correlation | Scatter (outside build_deck's category charts: use the pptx skill or an image) | Dual-axis lines |
| Exact values that matter | Table, numbers right-aligned, key row bold | A chart the audience must squint at |
Chart hygiene: no gridlines, direct data labels instead of a value axis, one highlighted bar, and the insight in the title rather than in the chart title. Order bars by value unless the categories have a natural order (time, stages).
## 8. Deck skeleton for a decision
1. Title
2. Executive summary (SCR plus the ask), the whole deck on one page
3. 3-5 key-line sections, each with 1-3 evidence slides
4. Recommendation and next steps (owners, dates)
5. Appendix: methodology, sensitivity, detailed tables
Keep a main body of 8-15 slides for a 30-minute discussion. Everything else goes to the appendix.
scripts/build_deck.py
#!/usr/bin/env python3
"""Build (or edit) a deck from a storyboard JSON by filling the TEMPLATE's own
layouts and placeholders. Never draws free-floating text boxes unless the spec
asks for one (`free_text`), or a layout has no source placeholder and a
`source` line must go somewhere (named 'tdb-source', reported in the log).
Usage:
python3 build_deck.py storyboard.json --template T.pptx -o out.pptx
python3 build_deck.py edits.json --edit existing.pptx -o out.pptx
See SKILL.md for the storyboard schema.
"""
import argparse
import copy
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pptx.chart.data import CategoryChartData # noqa: E402
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION # noqa: E402
from pptx.enum.dml import MSO_THEME_COLOR # noqa: E402
from pptx.enum.text import PP_ALIGN # noqa: E402
from pptx.shapes.placeholder import ChartPlaceholder, TablePlaceholder # noqa: E402
from pptx.util import Emu, Inches, Pt # noqa: E402
from inspect_template import classify # noqa: E402
from tdb_common import (NS, PP_PLACEHOLDER, TITLE_TYPES, content_placeholders, find_layout, # noqa: E402
is_source_ph, open_presentation, ph_type, rect, title_placeholder)
CHART_TYPES = {
"column": XL_CHART_TYPE.COLUMN_CLUSTERED, "stacked_column": XL_CHART_TYPE.COLUMN_STACKED,
"stacked_column_100": XL_CHART_TYPE.COLUMN_STACKED_100, "bar": XL_CHART_TYPE.BAR_CLUSTERED,
"stacked_bar": XL_CHART_TYPE.BAR_STACKED, "line": XL_CHART_TYPE.LINE_MARKERS,
"line_plain": XL_CHART_TYPE.LINE, "pie": XL_CHART_TYPE.PIE, "doughnut": XL_CHART_TYPE.DOUGHNUT,
"area": XL_CHART_TYPE.AREA,
}
ACCENTS = [MSO_THEME_COLOR.ACCENT_1, MSO_THEME_COLOR.ACCENT_2, MSO_THEME_COLOR.ACCENT_3,
MSO_THEME_COLOR.ACCENT_4, MSO_THEME_COLOR.ACCENT_5, MSO_THEME_COLOR.ACCENT_6]
NUMERIC_RE = re.compile(r"^[\s$€£+\-(]*[\d.,]+\s*(%|x|pp|pts?|bps|[kKmMbB]n?)?\)?\s*$")
CHART_OK = {PP_PLACEHOLDER.CHART, PP_PLACEHOLDER.OBJECT}
TABLE_OK = {PP_PLACEHOLDER.TABLE, PP_PLACEHOLDER.OBJECT}
class Log:
def __init__(self):
self.slides, self.errors, self.warnings = [], [], []
def err(self, n, msg):
self.errors.append(f"slide {n}: {msg}")
def warn(self, n, msg):
self.warnings.append(f"slide {n}: {msg}")
# ---------------------------------------------------------------- text
def _runs_from_markup(p, text, base_rpr=None):
"""Write `text` into paragraph p; **x** becomes a bold run."""
parts = re.split(r"(\*\*[^*]+\*\*)", text)
for part in parts:
if not part:
continue
r = p.add_run()
if base_rpr is not None:
old = r._r.find("a:rPr", NS)
if old is not None:
r._r.remove(old)
r._r.insert(0, copy.deepcopy(base_rpr))
if part.startswith("**") and part.endswith("**"):
r.text = part[2:-2]
r.font.bold = True
else:
r.text = part
def _flatten(items, level=0):
"""str | list (nested lists = sub-bullets) | {'text':..,'level':..} -> [(level, text)]."""
if items is None:
return []
if isinstance(items, str):
return [(level, line) for line in items.split("\n")]
if isinstance(items, dict):
return [(int(items.get("level", level)), items["text"])]
out = []
for it in items:
if isinstance(it, list):
out.extend(_flatten(it, level + 1))
else:
out.extend(_flatten(it, level))
return out
def write_text(shape, items, preserve=True):
"""Replace the text of a placeholder, keeping the first run's formatting
(so edits to an existing deck keep its look) and the inherited styles."""
tf = shape.text_frame
base_rpr = None
if preserve:
r0 = tf._txBody.find(".//a:p/a:r/a:rPr", NS)
base_rpr = copy.deepcopy(r0) if r0 is not None else None
if base_rpr is not None:
base_rpr.attrib.pop("b", None)
lines = _flatten(items)
tf.clear()
for i, (lvl, text) in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
for r in list(p.runs):
p._p.remove(r._r)
p.level = max(0, min(lvl, 8))
_runs_from_markup(p, text, base_rpr)
def remove_shape(shape):
el = shape._element
el.getparent().remove(el)
# ---------------------------------------------------------------- charts
def insert_chart(slide, ph, spec, n, log):
kind = spec.get("type", "column")
if kind not in CHART_TYPES:
log.err(n, f"chart type '{kind}' unknown; use one of {sorted(CHART_TYPES)}")
return None
cd = CategoryChartData(number_format=spec.get("number_format", "General"))
cd.categories = spec["categories"]
for s in spec["series"]:
cd.add_series(s["name"], s["values"])
ct = CHART_TYPES[kind]
if ph_type(ph) in CHART_OK:
gf = ChartPlaceholder(ph._element, ph._parent).insert_chart(ct, cd)
else: # text-only body placeholder: take its geometry, then drop it
l, t, w, h = rect(ph)
gf = slide.shapes.add_chart(ct, Emu(l), Emu(t), Emu(w), Emu(h), cd)
gf.name = f"tdb-chart@ph{ph.placeholder_format.idx}"
remove_shape(ph)
log.warn(n, "chart placed in the geometry of a text-only BODY placeholder (template has no chart/content placeholder here)")
style_chart(gf.chart, spec, kind)
return gf
def style_chart(chart, spec, kind):
"""Theme colors only (schemeClr), no gridlines, direct labels: consulting-clean."""
chart.font.size = Pt(spec.get("font_size", 12))
plot = chart.plots[0]
multi = len(spec["series"]) > 1
is_pie = kind in ("pie", "doughnut")
chart.has_legend = multi or is_pie
if chart.has_legend:
chart.legend.position = XL_LEGEND_POSITION.BOTTOM if not is_pie else XL_LEGEND_POSITION.RIGHT
chart.legend.include_in_layout = False
if "title" in spec:
chart.has_title = True
chart.chart_title.text_frame.text = spec["title"]
else:
chart.has_title = False
labels = spec.get("data_labels", True)
plot.has_data_labels = labels
if labels:
dl = plot.data_labels
dl.number_format = spec.get("label_format", spec.get("number_format", "General"))
dl.number_format_is_linked = False
dl.font.size = Pt(spec.get("font_size", 12))
if kind in ("column", "bar"):
dl.position = XL_LABEL_POSITION.OUTSIDE_END
if kind.startswith(("column", "bar", "stacked")):
plot.gap_width = spec.get("gap_width", 60)
if kind.startswith("stacked"):
plot.overlap = 100
if not is_pie:
va = chart.value_axis
va.has_major_gridlines = False
va.tick_labels.number_format = spec.get("number_format", "General")
va.tick_labels.number_format_is_linked = False
if labels and spec.get("hide_value_axis", True):
va.visible = False
if kind.startswith(("bar", "stacked_bar")):
chart.category_axis.reverse_order = True # first category on top, as read
if "value_axis_title" in spec:
va.has_title = True
va.axis_title.text_frame.text = spec["value_axis_title"]
highlight = spec.get("highlight")
if highlight is not None and not isinstance(highlight, list):
highlight = [highlight]
for i, s in enumerate(plot.series):
color = ACCENTS[i % len(ACCENTS)]
if kind.startswith("line"):
s.format.line.color.theme_color = color
s.format.line.width = Pt(2.25)
s.smooth = False
continue
if is_pie:
for j in range(len(spec["categories"])):
pt = s.points[j]
pt.format.fill.solid()
pt.format.fill.fore_color.theme_color = ACCENTS[j % len(ACCENTS)]
continue
s.format.fill.solid()
s.format.fill.fore_color.theme_color = color
if highlight is not None and not multi:
for j, cat in enumerate(spec["categories"]):
pt = s.points[j]
pt.format.fill.solid()
if j in highlight or cat in highlight:
pt.format.fill.fore_color.theme_color = MSO_THEME_COLOR.ACCENT_1
else: # muted theme neutral so the one message pops
pt.format.fill.fore_color.theme_color = MSO_THEME_COLOR.BACKGROUND_2
pt.format.fill.fore_color.brightness = -0.25
# ---------------------------------------------------------------- tables
def insert_table(slide, ph, spec, n, log):
header = spec.get("columns")
rows = spec["rows"]
data = ([header] if header else []) + rows
nr, nc = len(data), max(len(r) for r in data)
if ph_type(ph) in TABLE_OK:
gf = TablePlaceholder(ph._element, ph._parent).insert_table(nr, nc)
else:
l, t, w, h = rect(ph)
gf = slide.shapes.add_table(nr, nc, Emu(l), Emu(t), Emu(w), Emu(h))
gf.name = f"tdb-table@ph{ph.placeholder_format.idx}"
remove_shape(ph)
log.warn(n, "table placed in the geometry of a text-only BODY placeholder")
table = gf.table
table.first_row = bool(header)
total_w = gf.width
ratios = spec.get("col_widths")
if not ratios:
lens = [max(len(str(r[c])) if c < len(r) else 0 for r in data) for c in range(nc)]
ratios = [min(max(L, 6), 40) for L in lens]
s = float(sum(ratios))
widths = [int(total_w * r / s) for r in ratios]
widths[-1] = total_w - sum(widths[:-1])
for c, w in enumerate(widths):
table.columns[c].width = Emu(w)
size = spec.get("font_size") or (14 if nr <= 5 else 12 if nr <= 8 else 11 if nr <= 11 else 10)
row_h = int(gf.height / nr)
hl = spec.get("highlight_row")
for ri, row in enumerate(data):
table.rows[ri].height = Emu(row_h)
for ci in range(nc):
val = str(row[ci]) if ci < len(row) else ""
cell = table.cell(ri, ci)
cell.text = ""
p = cell.text_frame.paragraphs[0]
_runs_from_markup(p, val)
for r in p.runs:
r.font.size = Pt(size)
if hl is not None and ri == hl + (1 if header else 0):
r.font.bold = True
if ci > 0 and (NUMERIC_RE.match(val) or val in ("-", "n/a")):
p.alignment = PP_ALIGN.RIGHT
return gf
# ---------------------------------------------------------------- slides
def columns_of(phs, slide_w):
"""Group content placeholders into columns by left edge (within 1/20 slide width)."""
cols = []
for ph in sorted(phs, key=lambda p: (rect(p)[0], rect(p)[1])):
for col in cols:
if abs(rect(col[0])[0] - rect(ph)[0]) < slide_w / 20:
col.append(ph)
break
else:
cols.append([ph])
return [sorted(c, key=lambda p: rect(p)[1]) for c in cols]
def fill_body(slide, ph, spec, n, log):
if "chart" in spec:
return insert_chart(slide, ph, spec["chart"], n, log)
if "table" in spec:
return insert_table(slide, ph, spec["table"], n, log)
items = spec.get("bullets", spec.get("body"))
write_text(ph, items)
return ph
def fill_slide(prs, slide, spec, n, log, edit=False):
W, H = prs.slide_width, prs.slide_height
used = set()
info = {"slide": n, "layout": slide.slide_layout.name, "filled": [], "added_shapes": []}
def mark(ph, what):
used.add(ph.placeholder_format.idx)
info["filled"].append(f"ph{ph.placeholder_format.idx}:{what}")
# explicit placeholder overrides by idx
for key, val in (spec.get("placeholders") or {}).items():
try:
ph = slide.placeholders[int(key)]
except KeyError:
log.err(n, f"placeholder idx {key} not on layout '{slide.slide_layout.name}'")
continue
mark(ph, "explicit") # before filling: insert_chart/table invalidates ph
if isinstance(val, dict) and ("chart" in val or "table" in val):
fill_body(slide, ph, val, n, log)
else:
write_text(ph, val, preserve=edit)
if "title" in spec:
tph = title_placeholder(slide)
if tph is None:
log.err(n, f"spec has a title but layout '{slide.slide_layout.name}' has no title placeholder")
elif tph.placeholder_format.idx not in used:
write_text(tph, spec["title"], preserve=edit)
mark(tph, "title")
content = [p for p in content_placeholders(slide, H) if p.placeholder_format.idx not in used]
subs = [p for p in content if ph_type(p) == PP_PLACEHOLDER.SUBTITLE]
bodies = [p for p in content if ph_type(p) != PP_PLACEHOLDER.SUBTITLE]
bodies.sort(key=lambda p: -(rect(p)[2] * rect(p)[3]))
if "subtitle" in spec:
target = (subs or bodies or [None])[0]
if target is None:
log.err(n, "subtitle given but no subtitle/body placeholder on this layout")
else:
write_text(target, spec["subtitle"], preserve=edit)
mark(target, "subtitle")
bodies = [b for b in bodies if b is not target]
if "quote" in spec:
if not bodies:
log.err(n, "quote given but layout has no body placeholder")
else:
q = spec["quote"].strip().strip('"\u201c\u201d')
write_text(bodies[0], "\u201c" + q + "\u201d", preserve=edit)
mark(bodies[0], "quote")
if "attribution" in spec:
if len(bodies) > 1:
write_text(bodies[1], spec["attribution"], preserve=edit)
mark(bodies[1], "attribution")
else:
log.err(n, "attribution given but layout has only one body placeholder")
bodies = bodies[2:]
if "columns" in spec:
cols = columns_of(bodies, W)
if len(cols) < len(spec["columns"]):
log.err(n, f"{len(spec['columns'])} columns requested, layout '{slide.slide_layout.name}' has {len(cols)}; pick a two-column layout")
for col_spec, col in zip(spec["columns"], cols):
if len(col) >= 2 and "heading" in col_spec:
write_text(col[0], col_spec["heading"], preserve=edit)
mark(col[0], "column heading")
mark(col[1], "column body")
fill_body(slide, col[1], col_spec, n, log)
else:
cs = dict(col_spec)
if "heading" in cs and "chart" not in cs and "table" not in cs:
cs["bullets"] = [f"**{cs['heading']}**"] + [[b] if isinstance(b, str) else b for b in _as_list(cs.get("bullets"))]
mark(col[0], "column")
fill_body(slide, col[0], cs, n, log)
bodies = []
if any(k in spec for k in ("chart", "table", "bullets", "body")):
if not bodies:
log.err(n, f"content given but layout '{slide.slide_layout.name}' has no free content placeholder")
else:
target = bodies[0]
if "chart" in spec and ph_type(target) not in CHART_OK:
better = [b for b in bodies if ph_type(b) in CHART_OK]
target = better[0] if better else target
if "table" in spec and ph_type(target) not in TABLE_OK:
better = [b for b in bodies if ph_type(b) in TABLE_OK]
target = better[0] if better else target
if ("bullets" in spec or "body" in spec) and ph_type(target) in (PP_PLACEHOLDER.CHART, PP_PLACEHOLDER.TABLE):
log.err(n, "text given but the content placeholder is chart/table only; use a Title and Content layout")
else:
mark(target, "chart" if "chart" in spec else "table" if "table" in spec else "bullets")
fill_body(slide, target, spec, n, log)
if spec.get("source"):
src = [p for p in slide.placeholders if is_source_ph(p, H) and p.placeholder_format.idx not in used]
if src:
write_text(src[0], spec["source"], preserve=edit)
mark(src[0], "source")
else:
add_source_box(prs, slide, spec["source"])
info["added_shapes"].append("tdb-source")
log.warn(n, "layout has no source placeholder; added text box 'tdb-source' aligned to the content edge")
for ft in spec.get("free_text") or []:
tb = slide.shapes.add_textbox(Inches(ft["left_in"]), Inches(ft["top_in"]),
Inches(ft["width_in"]), Inches(ft["height_in"]))
tb.name = "tdb-free"
tb.text_frame.word_wrap = True
write_text(tb, ft["text"], preserve=False)
if ft.get("size_pt"):
for p in tb.text_frame.paragraphs:
for r in p.runs:
r.font.size = Pt(ft["size_pt"])
info["added_shapes"].append("tdb-free")
log.warn(n, "free-floating text box added because the spec asked for it (free_text)")
if "notes" in spec:
slide.notes_slide.notes_text_frame.text = spec["notes"]
if not spec.get("keep_empty_placeholders", False):
for ph in list(slide.placeholders):
if ph.placeholder_format.idx in used or ph_type(ph) in TITLE_TYPES and "title" in spec:
continue
if ph.has_text_frame and not ph.text_frame.text.strip():
info.setdefault("pruned", []).append(f"ph{ph.placeholder_format.idx}:{ph.name}")
remove_shape(ph)
log.slides.append(info)
def _as_list(x):
return [] if x is None else ([x] if isinstance(x, str) else list(x))
def add_source_box(prs, slide, text):
H = prs.slide_height
tph = title_placeholder(slide)
left = rect(tph)[0] if tph is not None and rect(tph) else Inches(0.5)
width = (rect(tph)[2] if tph is not None and rect(tph) else prs.slide_width - 2 * left)
bottoms = [rect(s)[1] + rect(s)[3] for s in slide.shapes if rect(s) and s is not tph]
# bottom-left, below everything else on the slide, never off the slide
top = min(max(max(bottoms or [0]) + Inches(0.05), H - Inches(0.55)), H - Inches(0.40))
tb = slide.shapes.add_textbox(Emu(left), Emu(top), Emu(width), Inches(0.32))
tb.name = "tdb-source"
tb.text_frame.word_wrap = True
write_text(tb, text, preserve=False)
for r in tb.text_frame.paragraphs[0].runs:
r.font.size = Pt(10)
r.font.color.theme_color = MSO_THEME_COLOR.TEXT_1
r.font.color.brightness = 0.35
def delete_slide(prs, slide):
lst = prs.slides._sldIdLst
for sldId in list(lst):
if prs.part.related_part(sldId.rId) is slide.part:
prs.part.drop_rel(sldId.rId)
lst.remove(sldId)
return
def recommended_layouts(prs):
best = {}
for layout in (l for m in prs.slide_masters for l in m.slide_layouts):
_, scores = classify(layout, prs.slide_width, prs.slide_height)
for k, v in scores.items():
if v >= 5 and v > best.get(k, (0, None))[0]:
best[k] = (v, layout)
return {k: v[1] for k, v in best.items()}
TYPE_ALIASES = {"chart": "one_message_chart", "content": "one_message_chart", "bullets": "agenda",
"cover": "title", "divider": "section", "two-column": "two_column"}
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("storyboard")
ap.add_argument("--template", help="company .pptx/.potx (overrides storyboard 'template')")
ap.add_argument("--edit", help="existing deck to edit in place (keeps masters, layouts, slides)")
ap.add_argument("-o", "--out", required=True)
a = ap.parse_args()
sb = json.loads(Path(a.storyboard).read_text())
base = a.edit or a.template or sb.get("template")
if not base:
sys.exit("No template: pass --template or set 'template' in the storyboard")
prs = open_presentation(base)
edit = bool(a.edit)
original = list(prs.slides)
if not edit and not sb.get("keep_template_slides", False):
for s in original:
delete_slide(prs, s)
original = []
rec = recommended_layouts(prs)
log = Log()
for i, spec in enumerate(sb.get("slides", []), 1):
if "edit" in spec:
k = int(spec["edit"])
if not 1 <= k <= len(original):
log.err(i, f"edit target slide {k} does not exist (deck has {len(original)})")
continue
fill_slide(prs, original[k - 1], spec, k, log, edit=True)
continue
try:
if "layout" in spec:
layout = find_layout(prs, spec["layout"])
else:
t = TYPE_ALIASES.get(spec.get("type"), spec.get("type"))
layout = rec.get(t)
if layout is None:
raise KeyError(f"no layout given and none suits type '{spec.get('type')}'")
except KeyError as e:
log.err(i, str(e).strip('"'))
continue
slide = prs.slides.add_slide(layout)
fill_slide(prs, slide, spec, len(prs.slides), log)
for k in sorted({int(x) for x in sb.get("delete", [])}, reverse=True):
if 1 <= k <= len(original):
delete_slide(prs, original[k - 1])
if sb.get("order"):
lst = prs.slides._sldIdLst
ids = list(lst)
for el in ids:
lst.remove(el)
for k in sb["order"]:
lst.append(ids[k - 1])
prs.save(a.out)
log_path = Path(a.out).with_suffix(".build_log.json")
log_path.write_text(json.dumps({"slides": log.slides, "errors": log.errors, "warnings": log.warnings}, indent=2))
for s in log.slides:
extra = f" added={s['added_shapes']}" if s["added_shapes"] else ""
pr = f" pruned={s['pruned']}" if s.get("pruned") else ""
print(f" slide {s['slide']}: [{s['layout']}] {', '.join(s['filled'])}{extra}{pr}")
for w in log.warnings:
print("WARN " + w)
for e in log.errors:
print("ERROR " + e)
print(f"Wrote {a.out} ({len(prs.slides)} slides); log {log_path}")
sys.exit(1 if log.errors else 0)
if __name__ == "__main__":
main()
scripts/inspect_template.py
#!/usr/bin/env python3
"""Map a company .pptx/.potx template so slides get built INTO it.
Writes layout_map.json with: slide size, theme colors + fonts, every master's
layouts with their placeholders (idx, type, name, geometry in EMU and inches),
which slide types each layout suits, a recommended layout per slide type, and
(for an existing deck) the slides already present.
Usage:
python3 inspect_template.py TEMPLATE.pptx [-o layout_map.json] [--quiet]
"""
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tdb_common import (CHROME_TYPES, CONTENT_TYPES, PP_PLACEHOLDER, TITLE_TYPES, # noqa: E402
emu_to_in, is_source_ph, open_presentation, ph_type, read_theme, rect)
SLIDE_TYPES = ["title", "section", "agenda", "one_message_chart", "two_column", "table", "quote", "title_only", "blank"]
NAME_HINTS = {
"title": r"\b(title slide|cover|opening)\b",
"section": r"section|divider|chapter|break",
"agenda": r"agenda|contents|overview",
"two_column": r"two|2[- ]?col|comparison|compare|split|side",
"quote": r"quote|testimonial|statement|big number|callout",
"table": r"table",
"one_message_chart": r"chart|content|exhibit|graph|body",
"title_only": r"title only",
"blank": r"blank",
}
def describe_placeholder(ph, slide_h):
r = rect(ph)
t = ph_type(ph)
d = {
"idx": ph.placeholder_format.idx,
"type": t.name if t is not None else None,
"name": ph.name,
"role": role_of(ph, slide_h),
}
if r:
d["emu"] = {"left": r[0], "top": r[1], "width": r[2], "height": r[3]}
d["inches"] = {"left": emu_to_in(r[0]), "top": emu_to_in(r[1]),
"width": emu_to_in(r[2]), "height": emu_to_in(r[3])}
return d
def role_of(ph, slide_h):
t = ph_type(ph)
if t in TITLE_TYPES:
return "title"
if t == PP_PLACEHOLDER.SUBTITLE:
return "subtitle"
if t in CHROME_TYPES:
return "chrome (date/footer/number: NOT copied to new slides by python-pptx)"
if is_source_ph(ph, slide_h):
return "source line"
if t == PP_PLACEHOLDER.CHART:
return "content (chart)"
if t == PP_PLACEHOLDER.TABLE:
return "content (table)"
if t in (PP_PLACEHOLDER.PICTURE, PP_PLACEHOLDER.BITMAP):
return "content (picture)"
if t == PP_PLACEHOLDER.OBJECT:
return "content (text, chart or table)"
if t == PP_PLACEHOLDER.BODY:
return "content (text only)"
return "other"
def classify(layout, slide_w, slide_h):
"""Return (suits, scores): structural fit first, name hints as tie-breakers."""
phs = list(layout.placeholders)
types = [ph_type(p) for p in phs]
has_ctr = PP_PLACEHOLDER.CENTER_TITLE in types
has_title = any(t in TITLE_TYPES for t in types)
has_sub = PP_PLACEHOLDER.SUBTITLE in types
content = [p for p in phs if ph_type(p) in CONTENT_TYPES and not is_source_ph(p, slide_h)]
area = slide_w * slide_h
big = [p for p in content if rect(p) and rect(p)[2] * rect(p)[3] > area * 0.25]
mid = [p for p in content if rect(p) and area * 0.08 < rect(p)[2] * rect(p)[3] <= area * 0.25]
name = layout.name.lower()
scores = {k: 0 for k in SLIDE_TYPES}
if has_ctr or (has_title and has_sub and not big):
scores["title"] += 6
if has_title and not content and not has_sub and not has_ctr:
scores["title_only"] += 5
if not phs or all(t in CHROME_TYPES for t in types):
scores["blank"] += 6
if has_title and not has_ctr and len(content) <= 1 and not big:
scores["section"] += 3
if has_title and len(big) == 1 and not mid:
t = ph_type(big[0])
# a typed chart/table/picture placeholder accepts only that content
fits = {PP_PLACEHOLDER.CHART: ("one_message_chart",), PP_PLACEHOLDER.TABLE: ("table",),
PP_PLACEHOLDER.PICTURE: (), PP_PLACEHOLDER.BITMAP: ()}.get(
t, ("one_message_chart", "agenda", "table"))
for k in fits:
scores[k] += 5
if t == PP_PLACEHOLDER.CHART:
scores["one_message_chart"] += 3
if t == PP_PLACEHOLDER.TABLE:
scores["table"] += 3
if t == PP_PLACEHOLDER.BODY: # text-only body: fine for agenda, chart goes in geometry
scores["agenda"] += 1
side = [p for p in content if rect(p) and rect(p)[3] > slide_h * 0.3]
if has_title and len(side) >= 2:
lefts = sorted({round(rect(p)[0] / (slide_w / 20)) for p in side})
if len(lefts) >= 2:
scores["two_column"] += 7
if (not has_title or not big) and content and any(rect(p) and rect(p)[2] > slide_w * 0.5 for p in content):
scores["quote"] += 2
for k, pat in NAME_HINTS.items():
if re.search(pat, name):
scores[k] += 3
suits = [k for k, v in sorted(scores.items(), key=lambda kv: -kv[1]) if v >= 5]
return suits, scores
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("template")
ap.add_argument("-o", "--out", default="layout_map.json")
ap.add_argument("--quiet", action="store_true")
a = ap.parse_args()
prs = open_presentation(a.template)
W, H = prs.slide_width, prs.slide_height
out = {
"source_file": str(a.template),
"slide_size": {"emu": [W, H], "inches": [emu_to_in(W), emu_to_in(H)],
"aspect": "16:9" if abs(W / H - 16 / 9) < 0.02 else ("4:3" if abs(W / H - 4 / 3) < 0.02 else f"{W / H:.3f}")},
"masters": [],
"recommended": {},
"existing_slides": [],
}
best = {k: (0, None) for k in SLIDE_TYPES}
for mi, master in enumerate(prs.slide_masters):
m = {"index": mi, "theme": read_theme(master),
"master_placeholders": [describe_placeholder(p, H) for p in master.placeholders],
"layouts": []}
for li, layout in enumerate(master.slide_layouts):
suits, scores = classify(layout, W, H)
m["layouts"].append({
"index": li, "name": layout.name, "suits": suits,
"used_by_slides": len(layout.used_by_slides),
"placeholders": [describe_placeholder(p, H) for p in layout.placeholders],
"non_placeholder_shapes": [s.name for s in layout.shapes if not s.is_placeholder],
})
for k, v in scores.items():
if v >= 5 and v > best[k][0]:
best[k] = (v, layout.name)
out["masters"].append(m)
out["recommended"] = {k: v[1] for k, v in best.items()}
for si, slide in enumerate(prs.slides, 1):
shapes = []
for s in slide.shapes:
d = {"name": s.name, "placeholder_idx": s.placeholder_format.idx if s.is_placeholder else None,
"kind": str(s.shape_type).split(".")[-1].split(" ")[0] if s.shape_type else "UNKNOWN"}
if s.has_text_frame and s.text_frame.text.strip():
d["text"] = s.text_frame.text.strip()[:120]
shapes.append(d)
out["existing_slides"].append({"slide": si, "layout": slide.slide_layout.name, "shapes": shapes})
Path(a.out).write_text(json.dumps(out, indent=2))
if not a.quiet:
th = out["masters"][0]["theme"]
print(f"Template: {a.template} size {out['slide_size']['inches']} in ({out['slide_size']['aspect']})")
print(f"Theme fonts: heading={th['fonts']['major']} body={th['fonts']['minor']}")
print("Theme colors: " + ", ".join(f"{k}={v}" for k, v in th["colors"].items()))
for m in out["masters"]:
for l in m["layouts"]:
phs = ", ".join(f"{p['idx']}:{p['type']}" for p in l["placeholders"] if not p["role"].startswith("chrome"))
print(f" [{m['index']}.{l['index']}] {l['name']!r:34} suits={l['suits'] or '-'} ph=[{phs}]")
print("Recommended: " + json.dumps(out["recommended"]))
print(f"Existing slides: {len(out['existing_slides'])}")
print(f"Wrote {a.out}")
if __name__ == "__main__":
main()
scripts/qa_deck.py
#!/usr/bin/env python3
"""Visual + structural QA for a deck built into a template.
Checks (per slide): estimated text overflow vs placeholder box (font-metric
approximation, flags reliance on shrink-on-overflow autofit), shapes outside
slide bounds, overlaps between text and other shapes, off-theme fonts and
colors, empty placeholders left behind, geometry drift from the layout,
action titles (length, full sentence), exhibits without a source line, missing
speaker notes, slide count vs storyboard. Renders via LibreOffice -> PDF ->
PNG and writes a contact sheet so the result gets LOOKED at.
Usage:
python3 qa_deck.py deck.pptx [--storyboard sb.json] [--out-dir qa/] [--no-render] [--strict]
Exit code 1 when any ERROR is found (always with --strict on WARN too).
"""
import argparse
import json
import math
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pptx.enum.shapes import MSO_SHAPE_TYPE # noqa: E402
from inspect_template import classify # noqa: E402
from tdb_common import (CHROME_TYPES, EMU_PER_INCH, EMU_PER_PT, NS, PP_PLACEHOLDER, TITLE_TYPES, # noqa: E402
open_presentation, ph_type, read_theme, rect)
# Helvetica/Arial advance widths (1/1000 em). Good enough to estimate wrapping.
_W = dict(zip(" !\"#$%&'()*+,-./0123456789:;<=>?@", [278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584,
278, 333, 278, 278] + [556] * 10 + [278, 278, 584, 584, 584, 556, 1015]))
_W.update(zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ", [667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722,
778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611]))
_W.update(zip("abcdefghijklmnopqrstuvwxyz", [556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556,
556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500]))
_W.update({"[": 278, "]": 278, "_": 556, "{": 334, "}": 334, "|": 260, "~": 584, "’": 222, "“": 333,
"”": 333, "–": 556, "—": 1000, "•": 350, "€": 556, "£": 556})
# width relative to Arial, by family (lowercase substring match)
FAMILY_FACTOR = {"georgia": 1.02, "verdana": 1.18, "tahoma": 1.02, "trebuchet": 1.0, "calibri": 0.90,
"aptos": 0.97, "cambria": 1.0, "times": 0.88, "garamond": 0.88, "segoe": 1.0,
"helvetica": 1.0, "arial narrow": 0.82, "arial": 1.0, "roboto": 0.98, "open sans": 1.06,
"lato": 0.97, "montserrat": 1.12, "inter": 1.03, "century gothic": 1.12, "futura": 1.02}
DEFAULT_SIZE_PT = 18.0
VERBISH = set("""is are was were be been being am will would can could should must may might shall has have had do does
did grows grew rises rose falls fell drives drove cuts cut needs need makes made lets let gets got takes took gives
gave shows showed beats beat lags lagged leads led outperforms outpaces trails remains stays requires delivers
enables unlocks captures costs saves pays loses lost wins won doubles halves exceeds reaches hits misses creates
carry carries generate generates recover recovers price prices charge charges face faces""".split())
NOUN_SUFFIX = ("tion", "tions", "sion", "sions", "ment", "ments", "ness", "ities", "ics", "ship", "ships", "ence", "ance",
"ss", "us", "is", "ous", "ess")
class Issue(dict):
pass
def issue(sev, slide, check, msg, shape=None):
return Issue(severity=sev, slide=slide, check=check, shape=shape, message=msg)
# ---------------------------------------------------------------- inheritance
def _lvl(lst, level):
return None if lst is None else lst.find(f"a:lvl{level + 1}pPr", NS)
class TextContext:
"""Resolves effective paragraph/run properties through the inheritance chain:
slide shape -> layout placeholder -> master placeholder -> master txStyles."""
def __init__(self, prs, slide, shape, theme):
self.shape, self.theme = shape, theme
self.lsts, self.bodyprs = [], []
tx = shape._element.find(".//p:txBody", NS)
if tx is None:
tx = shape._element.find(".//a:txBody", NS)
self.lsts.append(tx.find("a:lstStyle", NS) if tx is not None else None)
self.bodyprs.append(tx.find("a:bodyPr", NS) if tx is not None else None)
self.is_title = False
master = slide.slide_layout.slide_master
txstyles = master._element.find(".//p:txStyles", NS)
if shape.is_placeholder:
t = ph_type(shape)
idx = shape.placeholder_format.idx
self.is_title = t in TITLE_TYPES
lay_ph = next((p for p in slide.slide_layout.placeholders if p.placeholder_format.idx == idx), None)
mst_ph = None
want = "title" if self.is_title else {PP_PLACEHOLDER.DATE: "dt", PP_PLACEHOLDER.FOOTER: "ftr",
PP_PLACEHOLDER.SLIDE_NUMBER: "sldNum"}.get(t, "body")
for p in master.placeholders:
mt = ph_type(p)
cat = "title" if mt in TITLE_TYPES else {PP_PLACEHOLDER.DATE: "dt", PP_PLACEHOLDER.FOOTER: "ftr",
PP_PLACEHOLDER.SLIDE_NUMBER: "sldNum"}.get(mt, "body")
if cat == want:
mst_ph = p
break
for p in (lay_ph, mst_ph):
ptx = p._element.find(".//p:txBody", NS) if p is not None else None
self.lsts.append(ptx.find("a:lstStyle", NS) if ptx is not None else None)
self.bodyprs.append(ptx.find("a:bodyPr", NS) if ptx is not None else None)
self.lsts.append(txstyles.find("p:titleStyle" if self.is_title else "p:bodyStyle", NS)
if txstyles is not None else None)
else:
dts = prs.part._element.find("p:defaultTextStyle", NS)
self.lsts.append(dts)
self.lsts.append(txstyles.find("p:otherStyle", NS) if txstyles is not None else None)
def ppr(self, p_el, level):
chain = [p_el.find("a:pPr", NS)] + [_lvl(l, level) for l in self.lsts]
return [c for c in chain if c is not None]
def run_attr(self, r_el, p_el, level, attr):
rpr = r_el.find("a:rPr", NS) if r_el is not None else None
if rpr is not None and rpr.get(attr) is not None:
return rpr.get(attr)
for ppr in self.ppr(p_el, level):
d = ppr.find("a:defRPr", NS)
if d is not None and d.get(attr) is not None:
return d.get(attr)
return None
def typeface(self, r_el, p_el, level):
rpr = r_el.find("a:rPr", NS) if r_el is not None else None
el = rpr.find("a:latin", NS) if rpr is not None else None
if el is not None:
tf = el.get("typeface")
else:
tf = None
for ppr in self.ppr(p_el, level):
e = ppr.find("a:defRPr/a:latin", NS)
if e is not None:
tf = e.get("typeface")
break
if tf in (None, "+mn-lt"):
return self.theme["fonts"]["major" if (tf is None and self.is_title) else "minor"] or "Arial"
if tf == "+mj-lt":
return self.theme["fonts"]["major"] or "Arial"
return tf
def spacing(self, p_el, level, tag, size_pt):
for ppr in self.ppr(p_el, level):
e = ppr.find(f"a:{tag}", NS)
if e is not None:
pts, pct = e.find("a:spcPts", NS), e.find("a:spcPct", NS)
if pts is not None:
return ("pts", int(pts.get("val")) / 100.0)
if pct is not None:
return ("pct", int(pct.get("val")) / 100000.0)
return None
def marl(self, p_el, level):
for ppr in self.ppr(p_el, level):
if ppr.get("marL") is not None:
return int(ppr.get("marL"))
return 0
def body_attr(self, attr, default):
for b in self.bodyprs:
if b is not None and b.get(attr) is not None:
return b.get(attr)
return default
def autofit(self):
for b in self.bodyprs:
if b is None:
continue
for tag in ("normAutofit", "spAutoFit", "noAutofit"):
e = b.find(f"a:{tag}", NS)
if e is not None:
return tag, e
return None, None
# ---------------------------------------------------------------- text fit
SAFETY = 1.05 # renderer/kerning/substitution slack; calibrated against LibreOffice renders
def text_width_pt(text, size_pt, family, bold):
f = 1.06 # unknown family: assume a bit wider than Arial
fam = (family or "").lower()
for k, v in FAMILY_FACTOR.items():
if k in fam:
f = v
break
w = sum(_W.get(ch, 556) for ch in text)
return w / 1000.0 * size_pt * f * SAFETY * (1.06 if bold else 1.0)
def estimate_text(ctx, shape):
"""Return (needed_height_emu, available_height_emu, min_font_pt, fonts_used, autofit)."""
tx = shape.text_frame._txBody
r = rect(shape)
lins = int(ctx.body_attr("lIns", 91440))
rins = int(ctx.body_attr("rIns", 91440))
tins = int(ctx.body_attr("tIns", 45720))
bins = int(ctx.body_attr("bIns", 45720))
wrap = ctx.body_attr("wrap", "square") != "none"
fit_kind, fit_el = ctx.autofit()
scale = 1.0
ln_red = 0.0
if fit_kind == "normAutofit" and fit_el is not None:
scale = int(fit_el.get("fontScale", "100000")) / 100000.0
ln_red = int(fit_el.get("lnSpcReduction", "0")) / 100000.0
avail_w = (r[2] - lins - rins) / EMU_PER_PT
total = 0.0
sizes, fonts = [], set()
max_line_w = 0.0
for pi, p in enumerate(tx.findall("a:p", NS)):
ppr = p.find("a:pPr", NS)
level = int(ppr.get("lvl", "0")) if ppr is not None else 0
runs = p.findall("a:r", NS) + p.findall("a:fld", NS)
text = "".join((x.findtext("a:t", default="", namespaces=NS)) for x in runs)
first = runs[0] if runs else None
sz = ctx.run_attr(first, p, level, "sz")
size = (int(sz) / 100.0 if sz else DEFAULT_SIZE_PT)
size_nominal = size
size *= scale
bold = ctx.run_attr(first, p, level, "b") in ("1", "true")
fam = ctx.typeface(first, p, level)
fonts.add(fam)
if text.strip():
sizes.append(size_nominal)
width = avail_w - ctx.marl(p, level) / EMU_PER_PT
ls = ctx.spacing(p, level, "lnSpc", size)
line_h = size * 1.2 * ((ls[1] if ls and ls[0] == "pct" else 1.0) - ln_red)
if ls and ls[0] == "pts":
line_h = ls[1]
if not text:
lines = 1
elif not wrap:
lines = 1
max_line_w = max(max_line_w, text_width_pt(text, size, fam, bold))
else:
lines, cur = 1, 0.0
space = text_width_pt(" ", size, fam, bold)
for word in text.split(" "):
ww = text_width_pt(word, size, fam, bold)
if cur and cur + space + ww > width:
lines += 1
cur = ww
if ww > width:
lines += math.ceil(ww / width) - 1
else:
cur = cur + (space if cur else 0) + ww
before = ctx.spacing(p, level, "spcBef", size) if pi else None
after = ctx.spacing(p, level, "spcAft", size)
extra = 0.0
for sp in (before, after):
if sp:
extra += sp[1] if sp[0] == "pts" else sp[1] * size * 1.2
total += lines * line_h + extra
needed = int(total * EMU_PER_PT) + tins + bins
return {"needed": needed, "avail": r[3], "min_size": min(sizes) if sizes else None,
"fonts": fonts, "autofit": fit_kind, "scale": scale,
"nowrap_overflow": (not wrap and max_line_w * EMU_PER_PT > r[2] - lins - rins)}
# ---------------------------------------------------------------- title rules
def title_issues(n, text):
out = []
words = re.findall(r"[A-Za-z0-9%$'\-’.]+", text)
wc = len(words)
if wc > 15:
out.append(issue("WARN", n, "action_title", f"title is {wc} words; keep action titles to ~15 words / two lines"))
if 0 < wc < 5:
out.append(issue("WARN", n, "action_title", f"'{text}' reads like a topic label; state the takeaway as a sentence"))
low = [w.lower().strip(".,'’") for w in words]
has_verb = any(w in VERBISH for w in low)
if not has_verb:
for i, w in enumerate(low[1:], 1):
if len(w) > 3 and not w.endswith(NOUN_SUFFIX) and (w.endswith("ed") or (w.endswith("s") and not w.endswith("'s"))):
has_verb = True
break
if wc >= 5 and not has_verb:
out.append(issue("WARN", n, "action_title", "no verb found; an action title is a full sentence with a so-what (heuristic)"))
if text.rstrip().endswith((":", "?")):
out.append(issue("INFO", n, "action_title", "title ends with ':' or '?'; answer the question in the title instead"))
return out
# ---------------------------------------------------------------- geometry
def inter(a, b):
x1, y1 = max(a[0], b[0]), max(a[1], b[1])
x2, y2 = min(a[0] + a[2], b[0] + b[2]), min(a[1] + a[3], b[1] + b[3])
return max(0, x2 - x1) * max(0, y2 - y1)
def has_text(s):
return s.has_text_frame and s.text_frame.text.strip() != ""
def kind(s):
if s.shape_type == MSO_SHAPE_TYPE.PLACEHOLDER or s.is_placeholder:
if getattr(s, "has_chart", False) and s.has_chart:
return "chart"
if getattr(s, "has_table", False) and s.has_table:
return "table"
return "placeholder"
if getattr(s, "has_chart", False) and s.has_chart:
return "chart"
if getattr(s, "has_table", False) and s.has_table:
return "table"
if s.shape_type == MSO_SHAPE_TYPE.PICTURE:
return "picture"
if s.shape_type == MSO_SHAPE_TYPE.TEXT_BOX:
return "textbox"
return "shape"
# ---------------------------------------------------------------- main checks
def check_deck(path, storyboard=None):
prs = open_presentation(path)
W, H = prs.slide_width, prs.slide_height
issues, titles = [], []
for si, slide in enumerate(prs.slides, 1):
master = slide.slide_layout.slide_master
theme = read_theme(master)
theme_hex = set(theme["colors"].values())
theme_fonts = {f for f in theme["fonts"].values() if f} | {"+mj-lt", "+mn-lt", "+mj-ea", "+mn-ea", "+mj-cs", "+mn-cs"}
suits, _ = classify(slide.slide_layout, W, H)
framing = bool({"title", "section", "quote"} & set(suits[:1])) or "blank" in suits[:1]
shapes = list(slide.shapes)
sb_type = None
if storyboard and si <= len(storyboard.get("slides", [])):
sb_type = storyboard["slides"][si - 1].get("type")
if sb_type in ("title", "cover", "section", "divider", "quote", "closing"):
framing = True
slide_text = " ".join(s.text_frame.text for s in shapes if s.has_text_frame)
has_exhibit = False
title_text = None
for s in shapes:
r = rect(s)
k = kind(s)
if k in ("chart", "table", "picture"):
has_exhibit = has_exhibit or k in ("chart", "table")
if r is None:
continue
tol = int(0.01 * EMU_PER_INCH)
if r[0] < -tol or r[1] < -tol or r[0] + r[2] > W + tol or r[1] + r[3] > H + tol:
issues.append(issue("ERROR", si, "off_slide",
f"'{s.name}' extends outside the slide ({r[0] / EMU_PER_INCH:.2f},{r[1] / EMU_PER_INCH:.2f}) "
f"{r[2] / EMU_PER_INCH:.2f}x{r[3] / EMU_PER_INCH:.2f}in on {W / EMU_PER_INCH:.2f}x{H / EMU_PER_INCH:.2f}", s.name))
if not s.is_placeholder and k in ("textbox", "shape") and has_text(s):
sev = "INFO" if s.name == "tdb-source" else "WARN"
issues.append(issue(sev, si, "free_text_box",
f"'{s.name}' is a free-floating text box, not a template placeholder"
+ (" (source line fallback)" if sev == "INFO" else "; move the content into a layout placeholder"), s.name))
if s.is_placeholder:
t = ph_type(s)
if t in TITLE_TYPES and has_text(s):
title_text = s.text_frame.text.strip()
if s.has_text_frame and not has_text(s) and t not in CHROME_TYPES:
issues.append(issue("WARN", si, "empty_placeholder",
f"empty placeholder '{s.name}' (idx {s.placeholder_format.idx}) left behind; fill it or remove it", s.name))
if t in (PP_PLACEHOLDER.PICTURE,) and k == "placeholder":
issues.append(issue("WARN", si, "empty_placeholder", f"picture placeholder '{s.name}' has no picture", s.name))
# geometry drift from the layout grid
own = s._element.find("p:spPr/a:xfrm", NS)
if own is not None:
lay = next((p for p in slide.slide_layout.placeholders
if p.placeholder_format.idx == s.placeholder_format.idx), None)
if lay is not None and rect(lay):
d = max(abs(a - b) for a, b in zip(rect(lay), r)) / EMU_PER_INCH
if d > 0.05:
issues.append(issue("INFO", si, "layout_drift",
f"'{s.name}' moved/resized {d:.2f}in off the layout's position", s.name))
# text fit
if s.has_text_frame and has_text(s):
ctx = TextContext(prs, slide, s, theme)
est = estimate_text(ctx, s)
ratio = est["needed"] / max(est["avail"], 1)
where = f"'{s.name}': est. {est['needed'] / EMU_PER_INCH:.2f}in of text in a {est['avail'] / EMU_PER_INCH:.2f}in box ({ratio:.0%})"
if est["autofit"] == "spAutoFit":
ratio = 0 # the box grows with its text: check where the grown box ends instead
if r[1] + est["needed"] > H + int(0.01 * EMU_PER_INCH):
issues.append(issue("ERROR", si, "off_slide",
f"'{s.name}' resizes to fit its text and grows past the slide bottom", s.name))
if est["autofit"] == "normAutofit" and est["scale"] < 1.0:
issues.append(issue("WARN", si, "autofit_reliance",
f"'{s.name}' is shrunk to {est['scale']:.0%} by autofit; type sizes now differ from the template; cut text instead", s.name))
if ratio > 1.05:
msg = where + "; overflows"
if est["autofit"] == "normAutofit":
msg += " (shrink-on-overflow is set, so PowerPoint will shrink it on edit and LibreOffice on render; do not rely on it)"
issues.append(issue("ERROR", si, "text_overflow", msg + "; cut or split the content", s.name))
elif ratio > 0.92:
issues.append(issue("WARN", si, "text_tight", where + "; tight, may overflow with font substitution", s.name))
if est["nowrap_overflow"]:
issues.append(issue("ERROR", si, "text_overflow", f"'{s.name}' has wrap off and a line wider than the box", s.name))
if est["min_size"] and est["min_size"] < 10 and not s.name.startswith("tdb-source"):
issues.append(issue("WARN", si, "small_type", f"'{s.name}' has {est['min_size']:.0f}pt text; under 10pt is unreadable projected", s.name))
if k == "table":
issues.extend(check_table(si, s, H))
# overlaps: any text-bearing shape against any other visible shape
boxes = [(s, rect(s), kind(s)) for s in shapes if rect(s)]
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
(a, ra, ka), (b, rb, kb) = boxes[i], boxes[j]
if not (has_text(a) or has_text(b)):
continue
if ka == "placeholder" and not has_text(a) or kb == "placeholder" and not has_text(b):
continue
area = inter(ra, rb)
small = min(ra[2] * ra[3], rb[2] * rb[3]) or 1
if area / small > 0.04:
if max(ra[2] * ra[3], rb[2] * rb[3]) > 0.9 * W * H:
continue # full-bleed background shape
sev = "ERROR" if area / small > 0.15 else "WARN"
issues.append(issue(sev, si, "overlap",
f"'{a.name}' and '{b.name}' overlap ({area / small:.0%} of the smaller); text over another shape", a.name))
# theme fidelity in slide XML (+ chart parts)
xml_roots = [slide._element]
for s in shapes:
if getattr(s, "has_chart", False) and s.has_chart:
xml_roots.append(s.chart.part._element)
bad_colors, bad_fonts = set(), set()
for root in xml_roots:
for el in root.iter(f"{{{NS['a']}}}srgbClr"):
if el.get("val", "").upper() not in theme_hex:
# ignore colors inside pictures' effects
bad_colors.add(el.get("val").upper())
for tag in ("latin", "ea", "cs"):
for el in root.iter(f"{{{NS['a']}}}{tag}"):
tf = el.get("typeface")
if tf and tf not in theme_fonts and tag == "latin":
bad_fonts.add(tf)
if bad_colors:
issues.append(issue("WARN", si, "off_theme_color",
f"hard-coded colors not in the theme palette: {sorted(bad_colors)}; use theme colors (accent1..6, dk/lt)"))
if bad_fonts:
issues.append(issue("WARN", si, "off_theme_font",
f"fonts not in the theme ({theme['fonts']['major']}/{theme['fonts']['minor']}): {sorted(bad_fonts)}"))
if title_text is not None:
titles.append((si, title_text))
if not framing:
issues.extend(title_issues(si, title_text))
elif not framing and suits[:1] != ["blank"]:
issues.append(issue("WARN", si, "action_title", "content slide has no title; every content slide needs an action title"))
elif suits[:1] == ["blank"] or not slide.placeholders:
issues.append(issue("WARN", si, "template_bypass",
"slide uses a blank layout with hand-drawn shapes; rebuild it on a template layout"))
if re.search(r"\[TBD\]|\bTBD\b|\bXX+\b|lorem ipsum|click to (add|edit)", slide_text, re.I):
issues.append(issue("WARN", si, "placeholder_text", "unresolved placeholder text ([TBD], XX, lorem ipsum, 'Click to add')"))
if has_exhibit and "source" not in slide_text.lower():
issues.append(issue("WARN", si, "no_source", "chart/table without a source line"))
notes = slide.notes_slide.notes_text_frame.text.strip() if slide.has_notes_slide else ""
if not notes and not framing:
issues.append(issue("INFO", si, "no_notes", "no speaker notes"))
seen = {}
for si, t in titles:
if t.lower() in seen:
issues.append(issue("WARN", si, "duplicate_title", f"same title as slide {seen[t.lower()]}"))
seen[t.lower()] = si
n = len(prs.slides)
if storyboard is not None:
exp = storyboard.get("expected_slide_count")
if exp is None and not any("edit" in s for s in storyboard.get("slides", [])):
exp = len(storyboard.get("slides", []))
if exp is not None and exp != n:
issues.append(issue("ERROR", 0, "slide_count", f"deck has {n} slides, storyboard expects {exp}"))
return prs, issues, titles
def check_table(si, gf, H):
out = []
tbl = gf.table
total = 0
for ri, row in enumerate(tbl.rows):
row_need = row.height
for ci, cell in enumerate(row.cells):
text = cell.text_frame.text
if not text:
continue
sz = None
for p in cell.text_frame.paragraphs:
for r in p.runs:
if r.font.size:
sz = r.font.size.pt
size = sz or DEFAULT_SIZE_PT
width_pt = (tbl.columns[ci].width - 2 * 91440) / EMU_PER_PT
lines = 0
for para in text.split("\n"):
lines += max(1, math.ceil(text_width_pt(para, size, "arial", False) * 1.05 / max(width_pt, 1)))
need = int((lines * size * 1.2) * EMU_PER_PT) + 2 * 45720
row_need = max(row_need, need)
total += row_need
if gf.top + total > H:
out.append(issue("ERROR", si, "table_overflow",
f"table '{gf.name}' grows to ~{total / EMU_PER_INCH:.2f}in and runs off the slide; cut rows or split", gf.name))
elif total > gf.height * 1.08:
out.append(issue("WARN", si, "table_overflow",
f"table '{gf.name}' grows to ~{total / EMU_PER_INCH:.2f}in vs {gf.height / EMU_PER_INCH:.2f}in placeholder", gf.name))
return out
# ---------------------------------------------------------------- render
def render(deck, out_dir, dpi):
soffice = shutil.which("soffice") or shutil.which("libreoffice") or "/Applications/LibreOffice.app/Contents/MacOS/soffice"
pdftoppm = shutil.which("pdftoppm")
if not Path(soffice).exists() and not shutil.which(soffice):
return None, "LibreOffice (soffice) not found; skipped render"
if not pdftoppm:
return None, "pdftoppm (poppler) not found; skipped render"
slides_dir = out_dir / "slides"
slides_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / "lo_profile" # isolated profile: parallel soffice runs do not collide
cmd = [soffice, f"-env:UserInstallation=file://{profile}", "--headless", "--convert-to", "pdf",
"--outdir", tmp, str(deck)]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=240)
pdf = Path(tmp) / (Path(deck).stem + ".pdf")
if not pdf.exists():
return None, f"soffice failed: {res.stderr.strip()[:300]}"
shutil.copy(pdf, out_dir / "deck.pdf")
for old in slides_dir.glob("slide-*.png"):
old.unlink()
subprocess.run([pdftoppm, "-r", str(dpi), "-png", str(pdf), str(slides_dir / "slide")], check=True)
pngs = sorted(slides_dir.glob("slide-*.png"), key=lambda p: int(re.findall(r"(\d+)", p.stem)[-1]))
return pngs, None
def contact_sheet(pngs, issues, out_path, cols=3):
from PIL import Image, ImageDraw, ImageFont
thumbs = [Image.open(p).convert("RGB") for p in pngs]
tw = 420
th = int(thumbs[0].height * tw / thumbs[0].width)
pad, label = 14, 26
rows = math.ceil(len(thumbs) / cols)
sheet = Image.new("RGB", (cols * (tw + pad) + pad, rows * (th + label + pad) + pad), (236, 236, 236))
draw = ImageDraw.Draw(sheet)
try:
font = ImageFont.load_default(size=15)
except TypeError:
font = ImageFont.load_default()
for i, im in enumerate(thumbs):
n = i + 1
x = pad + (i % cols) * (tw + pad)
y = pad + (i // cols) * (th + label + pad)
sheet.paste(im.resize((tw, th)), (x, y + label))
errs = sum(1 for s in issues if s["slide"] == n and s["severity"] == "ERROR")
warns = sum(1 for s in issues if s["slide"] == n and s["severity"] == "WARN")
color = (180, 30, 30) if errs else ((170, 110, 0) if warns else (40, 40, 40))
draw.text((x, y + 4), f"Slide {n} {errs} error / {warns} warn", fill=color, font=font)
if errs:
draw.rectangle([x - 3, y + label - 3, x + tw + 2, y + label + th + 2], outline=color, width=3)
sheet.save(out_path)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("deck")
ap.add_argument("--storyboard")
ap.add_argument("--out-dir", default="qa")
ap.add_argument("--no-render", action="store_true")
ap.add_argument("--dpi", type=int, default=60)
ap.add_argument("--strict", action="store_true", help="exit 1 on WARN as well as ERROR")
a = ap.parse_args()
out = Path(a.out_dir)
out.mkdir(parents=True, exist_ok=True)
sb = json.loads(Path(a.storyboard).read_text()) if a.storyboard else None
prs, issues, titles = check_deck(a.deck, sb)
render_note, pngs = None, None
if not a.no_render:
pngs, render_note = render(a.deck, out, a.dpi)
if pngs:
if len(pngs) != len(prs.slides):
issues.append(issue("ERROR", 0, "render", f"rendered {len(pngs)} pages for {len(prs.slides)} slides"))
contact_sheet(pngs, issues, out / "contact_sheet.png")
order = {"ERROR": 0, "WARN": 1, "INFO": 2}
issues.sort(key=lambda i: (i["slide"], order[i["severity"]]))
counts = {k: sum(1 for i in issues if i["severity"] == k) for k in order}
lines = [f"# QA report: {Path(a.deck).name}", "",
f"Slides: {len(prs.slides)} ERROR {counts['ERROR']} WARN {counts['WARN']} INFO {counts['INFO']}", ""]
lines += ["## Storyline (titles only; should read as the argument)", ""]
lines += [f"{n}. {t}" for n, t in titles] + [""]
lines += ["## Issues", ""]
lines += [f"- [{i['severity']}] slide {i['slide'] or '-'} {i['check']}: {i['message']}" for i in issues] or ["- none"]
if render_note:
lines += ["", f"Render: {render_note}"]
elif pngs:
lines += ["", f"Render: {len(pngs)} PNGs in {out / 'slides'}; contact sheet {out / 'contact_sheet.png'}",
"Open the contact sheet and look at every slide: the estimates above miss what only eyes catch."]
report = "\n".join(lines)
(out / "qa_report.md").write_text(report + "\n")
(out / "qa_report.json").write_text(json.dumps({"counts": counts, "issues": issues,
"titles": titles}, indent=2))
print(report)
fail = counts["ERROR"] or (a.strict and counts["WARN"])
sys.exit(1 if fail else 0)
if __name__ == "__main__":
main()
scripts/tdb_common.py
"""Shared helpers for template-deck-builder scripts (python-pptx >= 0.6.21).
Kept in one module so inspect/build/qa agree on how a template is opened,
how theme values are read, and how placeholders are classified.
"""
import io
import re
import zipfile
from lxml import etree
from pptx import Presentation
from pptx.enum.shapes import PP_PLACEHOLDER
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
EMU_PER_INCH = 914400
EMU_PER_PT = 12700
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
}
POTX_CT = b"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"
PPTX_CT = b"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
# Placeholder types that hold the slide's main content (text, chart, table, picture).
CONTENT_TYPES = {
PP_PLACEHOLDER.OBJECT, PP_PLACEHOLDER.BODY, PP_PLACEHOLDER.CHART,
PP_PLACEHOLDER.TABLE, PP_PLACEHOLDER.PICTURE, PP_PLACEHOLDER.BITMAP,
PP_PLACEHOLDER.ORG_CHART, PP_PLACEHOLDER.MEDIA_CLIP,
}
TITLE_TYPES = {PP_PLACEHOLDER.TITLE, PP_PLACEHOLDER.CENTER_TITLE, PP_PLACEHOLDER.VERTICAL_TITLE}
CHROME_TYPES = {PP_PLACEHOLDER.DATE, PP_PLACEHOLDER.FOOTER, PP_PLACEHOLDER.SLIDE_NUMBER, PP_PLACEHOLDER.HEADER}
SOURCE_NAME_RE = re.compile(r"source|footnote|note", re.I)
def open_presentation(path):
"""Open .pptx or .potx. python-pptx rejects the .potx main content type,
so rewrite [Content_Types].xml in memory; nothing else differs."""
if str(path).lower().endswith((".potx", ".potm")):
buf_in = open(path, "rb").read()
zin = zipfile.ZipFile(io.BytesIO(buf_in))
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "[Content_Types].xml":
data = data.replace(POTX_CT, PPTX_CT)
zout.writestr(item, data)
out.seek(0)
return Presentation(out)
return Presentation(path)
def emu_to_in(v):
return None if v is None else round(v / EMU_PER_INCH, 3)
def read_theme(master):
"""Return {'colors': {dk1: 'RRGGBB', ...}, 'fonts': {'major': .., 'minor': ..}}."""
theme_part = master.part.part_related_by(RT.THEME)
root = etree.fromstring(theme_part.blob)
colors = {}
scheme = root.find(".//a:clrScheme", NS)
if scheme is not None:
for child in scheme:
tag = etree.QName(child).localname
srgb = child.find("a:srgbClr", NS)
sysc = child.find("a:sysClr", NS)
if srgb is not None:
colors[tag] = srgb.get("val").upper()
elif sysc is not None:
colors[tag] = (sysc.get("lastClr") or "000000").upper()
fonts = {}
for kind in ("major", "minor"):
el = root.find(f".//a:fontScheme/a:{kind}Font/a:latin", NS)
fonts[kind] = el.get("typeface") if el is not None else None
return {"name": root.get("name"), "colors": colors, "fonts": fonts}
def ph_type(ph):
try:
return ph.placeholder_format.type
except Exception:
return None
def rect(shape):
"""(left, top, width, height) in EMU; placeholders inherit from layout."""
try:
l, t, w, h = shape.left, shape.top, shape.width, shape.height
except Exception:
return None
if None in (l, t, w, h):
return None
return (int(l), int(t), int(w), int(h))
def is_source_ph(ph, slide_h):
"""A small body placeholder near the bottom, or one named Source/Footnote."""
if ph_type(ph) not in (PP_PLACEHOLDER.BODY, PP_PLACEHOLDER.OBJECT):
return False
if SOURCE_NAME_RE.search(ph.name or ""):
return True
r = rect(ph)
return bool(r and r[3] < slide_h * 0.08 and r[1] > slide_h * 0.8)
def content_placeholders(container, slide_h):
"""Content placeholders excluding source-line ones, sorted left-to-right, top-to-bottom."""
out = []
for ph in container.placeholders:
t = ph_type(ph)
if t in CONTENT_TYPES or t == PP_PLACEHOLDER.SUBTITLE:
if t != PP_PLACEHOLDER.SUBTITLE and is_source_ph(ph, slide_h):
continue
out.append(ph)
return sorted(out, key=lambda p: ((rect(p) or (0, 0))[0] // (EMU_PER_INCH // 4), (rect(p) or (0, 0, 0, 0))[1]))
def title_placeholder(container):
for ph in container.placeholders:
if ph_type(ph) in TITLE_TYPES:
return ph
return None
def find_layout(prs, name=None, index=None):
layouts = [l for m in prs.slide_masters for l in m.slide_layouts]
if index is not None:
return layouts[index]
for l in layouts:
if l.name == name:
return l
for l in layouts:
if l.name.strip().lower() == str(name).strip().lower():
return l
raise KeyError(f"Layout '{name}' not in template. Available: {[l.name for l in layouts]}")
SKILL.md
---
name: template-deck-builder
description: Builds and edits PowerPoint decks bound to a company's own .pptx or .potx template, with a consulting-grade storyline and a rendered visual QA pass. Maps the template's masters, layouts, placeholders, theme colors and fonts; drafts a storyboard of action titles (pyramid principle, SCR) for approval; fills the template's own placeholders instead of drawing text boxes; adds native charts in theme colors, tables sized to placeholders, speaker notes and source lines; then renders every slide to PNG and checks text overflow, off-slide and overlapping shapes, off-theme fonts and colors, empty placeholders and weak titles. Use whenever the user says use our template, company deck, brand or corporate template, make slides, build a deck, pptx, potx, consulting-style deck, board or client presentation, action titles, or fix my deck formatting, and whenever an existing deck must be edited without breaking its masters. Complements the generic pptx skill.
---
# Template Deck Builder
Build decks INTO the company template and make them argue something. The three ways decks go wrong are: rebuilding the template from scratch (floating text boxes, hard-coded colors, ignored layouts), having no storyline (topic-label titles, one slide per data dump), and shipping without looking (overflowing text, boxes over charts). This skill has one step for each.
Scripts need `python-pptx` (`pip install python-pptx`), plus LibreOffice (`soffice`) and poppler (`pdftoppm`) for the render. Anthropic's generic `pptx` skill covers raw pptx manipulation from scratch. Use this skill when a template exists or the deck has to read like consulting work.
## Files
- `scripts/inspect_template.py`: template to `layout_map.json` (layouts, placeholders, geometry, theme, recommended layout per slide type).
- `scripts/build_deck.py`: storyboard JSON to deck, filling placeholders. Also edits an existing deck in place (`--edit`).
- `scripts/qa_deck.py`: checks, render, contact sheet, report. Exits 1 on any ERROR.
- `references/storyboard-schema.md`: every storyboard field, with examples.
- `references/storyline.md`: action titles, pyramid, SCR, ghost deck, chart choice.
- `references/python-pptx-notes.md`: placeholder mechanics, autofit limits, EMU, charts, breakages.
- `tests/run_tests.sh OUT_DIR`: builds a sample template, a good 6-slide deck and a bad one, and checks that QA passes the first and catches the second.
## Workflow
### 1. Inspect the template (never skip)
```bash
python3 scripts/inspect_template.py company.potx -o layout_map.json
```
Read the printed layout list and `recommended`. From here on, every slide uses one of these layouts by exact name. Note the theme fonts and colors. They are the only fonts and colors allowed. If the template holds sample slides, the build drops them by default (`keep_template_slides: true` keeps them).
If no layout fits a slide type (for example no two-column layout), say so and pick the closest one. Do not draw your own. A deck that ignores the layouts breaks the moment someone applies a new theme or edits it in PowerPoint.
### 2. Write the storyline before any slide
Read `references/storyline.md`, then give the user:
1. The governing thought: one sentence, the answer to the audience's question.
2. The ghost deck: one action title per slide, numbered, with its layout and a one-line note on the evidence (chart type, table, bullets).
Test it. Read the titles alone, top to bottom. They must make the whole argument in order, and each must be a full sentence with a so-what, around 15 words or fewer. "Q3 revenue" is a label. "Q3 revenue fell 8% because two accounts churned" is a title. Every slide carries one message, and its body only proves that message.
**Stop and get the storyline approved.** Changing a title list costs seconds. Rebuilding 20 slides costs an hour.
### 3. Write the storyboard JSON
Follow `references/storyboard-schema.md`. Each slide gets `layout` (exact name) or `type`, `title`, one content block (`bullets`, `chart`, `table`, `columns`, `quote`), `source`, and `notes`. Rules:
- Charts carry real numbers and a `number_format`. Use `highlight` for the bar that makes the point. The chart type follows from the message (see storyline.md).
- Every chart or table gets a `source`. Every content slide gets `notes`, which hold what the presenter says, not the slide text again.
- Bullets: at most 6, one line each where possible, parallel grammar. Use `**Lead-in:** detail` for scannable bullets.
- Use `free_text` only when the user asks for an annotation the template has no placeholder for. It is flagged in QA on purpose.
### 4. Build into the placeholders
```bash
python3 scripts/build_deck.py storyboard.json --template company.potx -o deck.pptx
```
The build fills title, subtitle, body, chart, table, quote and source placeholders by role. Charts and tables go into the content placeholder itself, so they inherit its position. Charts use theme accent colors (schemeClr), so a theme change recolors them. Speaker notes are written, and unused empty placeholders are removed. Read the build log. Any `ERROR` (unknown layout, content with no placeholder to hold it) means fix the storyboard and rebuild. Never patch the output by hand with text boxes.
### 5. QA: render, look, fix, repeat
```bash
python3 scripts/qa_deck.py deck.pptx --storyboard storyboard.json --out-dir qa/
```
Then open `qa/contact_sheet.png` and look at every slide. The checks estimate text fit from font metrics. The render shows what the checks miss: an unbalanced layout, a chart that says nothing, a label collision.
Fix at the source, in order:
- `text_overflow` / `text_tight`: cut words, split into two slides, or move detail to notes. Do not shrink fonts, and do not rely on "shrink text on overflow". PowerPoint recomputes that on edit, so type sizes drift from slide to slide.
- `overlap`, `off_slide`, `free_text_box`, `template_bypass`: move the content into a layout placeholder, or change the layout.
- `off_theme_font` / `off_theme_color`: remove the hard-coded value so the theme applies.
- `empty_placeholder`: fill it or drop it. Empty ones show "Click to add text" in edit view.
- `action_title`: rewrite as a full-sentence takeaway. Re-read the storyline section of the report.
- `no_source`: add the source line.
Rebuild and rerun QA until it reports 0 ERROR and every WARN is fixed or explained to the user. Only then deliver.
### 6. Deliver
Deliver `deck.pptx`, the contact sheet, and a short note: the governing thought, the slide count, and any WARN you chose to leave with the reason. Offer the ghost deck (the titles) as the executive summary.
## Editing an existing deck
The goal is to change the content and keep the design system.
1. Run `inspect_template.py deck.pptx`. `existing_slides` lists each slide's layout and its placeholder shapes and text.
2. Write an edit storyboard: `{"slides": [{"edit": 4, "title": "...", "bullets": [...]}], "delete": [7], "order": [...]}`. Edits replace text inside the existing placeholders and keep the first run's formatting. New slides without `edit` are appended from the deck's own layouts.
3. `build_deck.py edits.json --edit deck.pptx -o deck_v2.pptx`, then QA with `expected_slide_count`.
Never re-create the deck to change it, and never copy slides into a fresh file. Either one silently drops the masters. For "fix my deck formatting", run QA first and fix what it finds, working from ERRORs down.
## Guardrails
- The template decides fonts, colors, positions and bullet styles. The storyboard decides words and numbers.
- One message per slide. If a title needs "and", it is probably two slides.
- Numbers on slides must come from the user's data. Mark placeholders as `[TBD]` rather than inventing figures, and QA will show them.
- Keep text at 10pt or larger (source lines excepted). QA warns below that.
tests/make_sample_template.py
#!/usr/bin/env python3
"""Generate a sample corporate template for testing: 16:9, custom theme colors and
fonts, renamed and custom layouts (Cover, Title and Content, Section Divider, Two Column,
Title and Chart, Quote, Title Only, Blank), a dedicated Source placeholder on content
layouts, and a decorative accent bar on the master. Writes .pptx and .potx.
Usage: python3 make_sample_template.py OUT_DIR
"""
import copy
import io
import sys
import zipfile
from pathlib import Path
from lxml import etree
from pptx import Presentation
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from pptx.util import Emu, Inches
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
NS = {"a": A, "p": P}
COLORS = {"dk1": "1A1A1A", "lt1": "FFFFFF", "dk2": "14213D", "lt2": "E7E9EE",
"accent1": "1F5AA6", "accent2": "D08C2B", "accent3": "5B6B82", "accent4": "9FB3CF",
"accent5": "B5462F", "accent6": "2B8A9E", "hlink": "1F5AA6", "folHlink": "5B6B82"}
FONTS = {"major": "Georgia", "minor": "Arial"}
def set_theme(prs):
part = prs.slide_master.part.part_related_by(RT.THEME)
root = etree.fromstring(part.blob)
root.set("name", "Sample Corporate")
scheme = root.find(".//a:clrScheme", NS)
scheme.set("name", "Sample Corporate")
for child in list(scheme):
tag = etree.QName(child).localname
for c in list(child):
child.remove(c)
etree.SubElement(child, f"{{{A}}}srgbClr", val=COLORS[tag])
for kind in ("major", "minor"):
root.find(f".//a:fontScheme/a:{kind}Font/a:latin", NS).set("typeface", FONTS[kind])
part._blob = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
def scale_x(shapes, k):
"""Only shapes with their OWN xfrm. Setting .left on a placeholder that inherits
its position creates a new xfrm with y=0 and height=0 (python-pptx gotcha)."""
for s in shapes:
if s._element.find("p:spPr/a:xfrm", NS) is not None:
s.left, s.width = Emu(int(s.left * k)), Emu(int(s.width * k))
def style_master(prs):
m = prs.slide_master
tx = m._element.find(".//p:txStyles", NS)
t1 = tx.find("p:titleStyle/a:lvl1pPr", NS)
t1.set("algn", "l")
t1.find("a:defRPr", NS).set("sz", "2600")
t1.find("a:defRPr", NS).set("b", "0")
for lvl, sz in (("lvl1pPr", "1800"), ("lvl2pPr", "1600"), ("lvl3pPr", "1400")):
tx.find(f"p:bodyStyle/a:{lvl}", NS).find("a:defRPr", NS).set("sz", sz)
# decorative accent bar on the master (a template shape, not content)
bar = etree.fromstring(
f'<p:sp xmlns:p="{P}" xmlns:a="{A}"><p:nvSpPr><p:cNvPr id="90" name="Accent Bar"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
f'<p:spPr><a:xfrm><a:off x="{Inches(0.6)}" y="{Inches(0.25)}"/><a:ext cx="{Inches(0.9)}" cy="{Inches(0.06)}"/></a:xfrm>'
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="accent2"/></a:solidFill><a:ln><a:noFill/></a:ln></p:spPr></p:sp>')
m.shapes._spTree.append(bar)
def add_placeholder(layout, clone_from, idx, name, box, size_pt=None, bullets=True):
el = copy.deepcopy(clone_from._element)
el.find(".//p:cNvPr", NS).set("id", str(100 + idx))
el.find(".//p:cNvPr", NS).set("name", name)
ph = el.find(".//p:nvPr/p:ph", NS)
ph.set("idx", str(idx))
ph.attrib.pop("type", None)
ph.set("type", "body")
ph.set("sz", "quarter")
sppr = el.find("p:spPr", NS)
for old in sppr.findall("a:xfrm", NS):
sppr.remove(old)
xfrm = etree.Element(f"{{{A}}}xfrm")
etree.SubElement(xfrm, f"{{{A}}}off", x=str(int(box[0])), y=str(int(box[1])))
etree.SubElement(xfrm, f"{{{A}}}ext", cx=str(int(box[2])), cy=str(int(box[3])))
sppr.insert(0, xfrm)
txb = el.find(".//p:txBody", NS)
for old in txb.findall("a:lstStyle", NS):
txb.remove(old)
lst = etree.SubElement(txb, f"{{{A}}}lstStyle")
txb.insert(1, lst)
lvl = etree.SubElement(lst, f"{{{A}}}lvl1pPr", marL="0", indent="0")
if not bullets:
etree.SubElement(lvl, f"{{{A}}}buNone")
if size_pt:
etree.SubElement(lvl, f"{{{A}}}defRPr", sz=str(int(size_pt * 100)))
for p in txb.findall("a:p", NS):
txb.remove(p)
p = etree.SubElement(txb, f"{{{A}}}p")
r = etree.SubElement(p, f"{{{A}}}r")
etree.SubElement(r, f"{{{A}}}rPr", lang="en-US")
etree.SubElement(r, f"{{{A}}}t").text = name
layout.shapes._spTree.append(el)
def reset_text_style(ph):
"""Empty the placeholder's lstStyle and bodyPr so it inherits from the master."""
txb = ph._element.find(".//p:txBody", NS)
for tag in ("a:bodyPr", "a:lstStyle"):
el = txb.find(tag, NS)
for c in list(el):
el.remove(c)
el.attrib.clear()
def ph_by_type(layout, t):
return [p for p in layout.placeholders if p.placeholder_format.type == t]
def main(out_dir):
from pptx.enum.shapes import PP_PLACEHOLDER as T
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
prs = Presentation()
k = (13.333 / 10.0)
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
scale_x(prs.slide_master.shapes, k)
for l in prs.slide_layouts:
scale_x(l.shapes, k)
set_theme(prs)
style_master(prs)
L = list(prs.slide_layouts)
cover_title = ph_by_type(L[0], T.CENTER_TITLE)[0]
lst = cover_title._element.find(".//a:lstStyle", NS)
lvl = etree.SubElement(lst, f"{{{A}}}lvl1pPr", algn="ctr")
etree.SubElement(lvl, f"{{{A}}}defRPr", sz="3600")
names = {0: "Cover", 1: "Title and Content", 2: "Section Divider", 3: "Two Column",
5: "Title Only", 6: "Blank", 7: "Quote", 8: "Title and Chart"}
for i, n in names.items():
L[i].name = n
src_box = (Inches(0.6), Inches(6.88), Inches(12.1), Inches(0.32))
# content layouts: tighten geometry to a consulting grid, make every title and body
# inherit the master styles (the stock layouts override sizes), add a Source line
for i in (1, 3, 8):
lay = L[i]
title = ph_by_type(lay, T.TITLE)[0]
title.left, title.top, title.width, title.height = Inches(0.6), Inches(0.4), Inches(12.1), Inches(1.1)
for ph in lay.placeholders:
if ph.placeholder_format.type in (T.TITLE, T.OBJECT, T.BODY, T.PICTURE):
reset_text_style(ph)
body = [p for p in lay.placeholders if p.placeholder_format.type in (T.OBJECT, T.BODY, T.PICTURE)]
add_placeholder(lay, body[0], 20, "Source", src_box, size_pt=10, bullets=False)
content = ph_by_type(L[1], T.OBJECT)[0]
content.left, content.top, content.width, content.height = Inches(0.6), Inches(1.7), Inches(12.1), Inches(5.0)
two = ph_by_type(L[3], T.OBJECT)
for j, p in enumerate(sorted(two, key=lambda p: p.left)):
p.left, p.top, p.width, p.height = Inches(0.6 + j * 6.2), Inches(1.7), Inches(5.9), Inches(5.0)
# Title and Chart: picture placeholder becomes a chart placeholder; drop the caption
lay = L[8]
pic = ph_by_type(lay, T.PICTURE)[0]
pic._element.find(".//p:nvPr/p:ph", NS).set("type", "chart")
pic.left, pic.top, pic.width, pic.height = Inches(0.6), Inches(1.7), Inches(12.1), Inches(5.0)
for p in ph_by_type(lay, T.BODY):
if p.placeholder_format.idx != 20:
p._element.getparent().remove(p._element)
# Quote: keep one body as the quote, add an attribution line, drop title + content
lay = L[7]
for p in list(lay.placeholders):
if p.placeholder_format.type in (T.TITLE, T.OBJECT):
p._element.getparent().remove(p._element)
q = ph_by_type(lay, T.BODY)[0]
q.left, q.top, q.width, q.height = Inches(1.6), Inches(1.9), Inches(10.1), Inches(2.9)
q.name = "Quote"
add_placeholder(lay, q, 21, "Attribution", (Inches(1.6), Inches(5.0), Inches(10.1), Inches(0.6)), size_pt=14, bullets=False)
txb = q._element.find(".//p:txBody", NS)
lst = txb.find("a:lstStyle", NS)
if lst is None:
lst = etree.Element(f"{{{A}}}lstStyle")
txb.insert(1, lst)
for old in lst.findall("a:lvl1pPr", NS):
lst.remove(old)
lvl = etree.Element(f"{{{A}}}lvl1pPr", marL="0", indent="0")
lst.insert(0, lvl)
etree.SubElement(lvl, f"{{{A}}}buNone")
etree.SubElement(lvl, f"{{{A}}}defRPr", sz="2800", i="1")
# drop layouts the house style does not use (Comparison, vertical text ones)
for i in (10, 9, 4):
prs.slide_layouts.remove(L[i])
pptx_path = out_dir / "sample_template.pptx"
prs.save(pptx_path)
# .potx copy: identical package, template main content type
data = io.BytesIO()
zin = zipfile.ZipFile(pptx_path)
with zipfile.ZipFile(data, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
b = zin.read(item.filename)
if item.filename == "[Content_Types].xml":
b = b.replace(b"presentationml.presentation.main+xml", b"presentationml.template.main+xml")
zout.writestr(item, b)
(out_dir / "sample_template.potx").write_bytes(data.getvalue())
print(f"Wrote {pptx_path} and {out_dir / 'sample_template.potx'}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else ".")
tests/run_tests.sh
#!/usr/bin/env bash
# End-to-end test: sample template -> inspect -> good build + QA (expect 0 errors)
# -> bad build + sloppy hand edit + QA (expect errors caught). Output goes to $1.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
S="$HERE/../scripts"
OUT="${1:?usage: run_tests.sh OUT_DIR (keep it outside the skill folder)}"
mkdir -p "$OUT"
python3 -c "import pptx" 2>/dev/null || { echo "python-pptx missing: pip install python-pptx"; exit 2; }
echo "== 1. sample template"
python3 "$HERE/make_sample_template.py" "$OUT" || exit 1
echo "== 2. inspect (.potx path)"
python3 "$S/inspect_template.py" "$OUT/sample_template.potx" -o "$OUT/layout_map.json" || exit 1
echo "== 3. good storyboard: build + QA (expect exit 0)"
python3 "$S/build_deck.py" "$HERE/storyboard_good.json" --template "$OUT/sample_template.potx" -o "$OUT/good.pptx" || exit 1
python3 "$S/qa_deck.py" "$OUT/good.pptx" --storyboard "$HERE/storyboard_good.json" --out-dir "$OUT/qa_good"
good=$?
echo "== 4. bad storyboard: build + sloppy edit + QA (expect exit 1)"
python3 "$S/build_deck.py" "$HERE/storyboard_bad.json" --template "$OUT/sample_template.potx" -o "$OUT/bad_built.pptx"
python3 "$HERE/sloppy_edit.py" "$OUT/bad_built.pptx" "$OUT/bad.pptx" || exit 1
python3 "$S/qa_deck.py" "$OUT/bad.pptx" --storyboard "$HERE/storyboard_bad.json" --out-dir "$OUT/qa_bad"
bad=$?
echo "== 5. edit mode: change slide 3 title + notes in place, keep masters"
cat > "$OUT/edits.json" <<'JSON'
{"slides": [{"edit": 3, "title": "Twelve lanes drive 80% of the loss, so the fix is targeted", "notes": "Edited in place."}],
"expected_slide_count": 6}
JSON
python3 "$S/build_deck.py" "$OUT/edits.json" --edit "$OUT/good.pptx" -o "$OUT/good_edited.pptx" || exit 1
python3 "$S/qa_deck.py" "$OUT/good_edited.pptx" --storyboard "$OUT/edits.json" --out-dir "$OUT/qa_edit" --no-render
edit=$?
echo
echo "RESULT good_qa_exit=$good (want 0) bad_qa_exit=$bad (want 1) edit_qa_exit=$edit (want 0)"
[ "$good" = 0 ] && [ "$bad" = 1 ] && [ "$edit" = 0 ] && echo PASS || { echo FAIL; exit 1; }
tests/sloppy_edit.py
#!/usr/bin/env python3
"""Simulate a hand-edited deck that ignored the template: an off-theme font and
color on slide 1, plus an extra slide 'remade from scratch' on the Blank layout
with drawn boxes. Used to prove qa_deck.py catches template bypass.
Usage: python3 sloppy_edit.py IN.pptx OUT.pptx"""
import sys
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches, Pt
prs = Presentation(sys.argv[1])
title = prs.slides[0].shapes.title
for p in title.text_frame.paragraphs:
for r in p.runs:
r.font.name = "Comic Sans MS"
r.font.color.rgb = RGBColor(0xFF, 0x00, 0xFF)
blank = next(l for l in prs.slide_layouts if l.name == "Blank")
s = prs.slides.add_slide(blank)
box = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.5), Inches(0.5), Inches(12.3), Inches(1.2))
box.fill.solid(); box.fill.fore_color.rgb = RGBColor(0x33, 0x99, 0xFF)
tb = s.shapes.add_textbox(Inches(0.7), Inches(0.7), Inches(11), Inches(0.8))
tb.text_frame.text = "Next steps"
tb.text_frame.paragraphs[0].runs[0].font.size = Pt(36)
tb.text_frame.paragraphs[0].runs[0].font.name = "Helvetica Neue"
prs.save(sys.argv[2])
print("Wrote", sys.argv[2])
tests/storyboard_bad.json
{
"governing_thought": "Deliberately bad spec: QA must catch every problem on these slides.",
"template": "sample_template.pptx",
"slides": [
{
"layout": "Title and Content",
"title": "Overview of the various operational and financial considerations regarding the lane pricing review for the upcoming fiscal year planning cycle",
"bullets": [
"Operating margin has declined from 7.8% to 4.9% over the last two fiscal years even though total shipment volume grew by 11% across all regions and customer segments",
"A small number of lanes appear to be responsible for the majority of the losses, but the exact figures depend on how shared overhead costs are allocated across the network",
"Competitor pricing on comparable lanes appears to be higher than ours by a meaningful margin according to broker benchmarks and several publicly available tariff filings",
"Customers have indicated in interviews that they value guaranteed capacity and predictable surcharges more than the absolute price level on the most congested lanes",
"Any pricing change would need to be coordinated with the contract renewal calendar, the sales compensation plan, and the customer communications team",
"There are additional considerations regarding fuel surcharges, accessorial charges, and minimum-volume commitments that should be reviewed as part of this work",
"The finance team has also raised questions about the impact on working capital and on the timing of revenue recognition for multi-year contracts"
]
},
{
"layout": "Two Column",
"title": "Market context",
"columns": [
{"bullets": ["Competitors charge more"]}
],
"keep_empty_placeholders": true,
"notes": "Only one column filled; the other placeholder is left empty."
},
{
"layout": "Title and Chart",
"title": "Lane profitability",
"chart": {
"type": "column",
"categories": ["Top 12", "Next 28", "Rest"],
"series": [{"name": "Loss share", "values": [0.8, 0.15, 0.05]}],
"number_format": "0%"
},
"free_text": [
{"text": "KEY TAKEAWAY: the top lanes are the problem and we should probably do something about them soon", "left_in": 9.0, "top_in": 5.4, "width_in": 5.5, "height_in": 2.6, "size_pt": 20}
]
},
{
"type": "section",
"layout": "Section Divider",
"title": "Appendix"
}
]
}
tests/storyboard_good.json
{
"governing_thought": "Acme Freight can recover three points of operating margin within 18 months by repricing twelve loss-making lanes, without cutting headcount.",
"template": "sample_template.pptx",
"slides": [
{
"type": "title",
"layout": "Cover",
"title": "Recovering three points of margin by repricing twelve lanes",
"subtitle": "Board discussion document | September 2026",
"notes": "Open with the answer: the margin gap is a pricing problem on a small set of lanes, not a cost problem."
},
{
"type": "agenda",
"layout": "Title and Content",
"title": "Repricing twelve loss-making lanes recovers three margin points without cutting headcount",
"bullets": [
"**Situation:** operating margin fell from 7.8% to 4.9% over two years while volume grew 11%",
"**Complication:** twelve of 140 lanes generate 80% of the operating loss",
"**Resolution:** reprice those lanes in two waves, 9-14% up, in line with competitors",
["Wave 1 (Q1-Q2): six lanes with the weakest customer switching options"],
["Wave 2 (Q3-Q4): six lanes tied to contract renewals"],
"**Decision today:** approve Wave 1 pricing and the customer communication plan"
],
"source": "Source: Acme Freight lane P&L, FY2024-FY2026; team analysis",
"notes": "This is the whole deck on one page. If the board reads nothing else, it reads this."
},
{
"type": "one_message_chart",
"layout": "Title and Chart",
"title": "Twelve of 140 lanes generate 80% of the operating loss",
"chart": {
"type": "bar",
"categories": ["Top 12 lanes", "Next 28 lanes", "Remaining 100 lanes"],
"series": [{"name": "Share of operating loss", "values": [0.80, 0.15, 0.05]}],
"number_format": "0%",
"highlight": 0
},
"source": "Source: Acme Freight lane P&L, FY2026 year to date; n = 140 active lanes",
"notes": "The concentration is the insight: fixing a small set of lanes fixes most of the problem."
},
{
"type": "two_column",
"layout": "Two Column",
"title": "Competitors already charge 9-14% more on these lanes, so the increase is defensible",
"columns": [
{"heading": "What we charge today", "bullets": ["$2.41 per mile average on the twelve lanes", "Rates last reset in 2023", "Fuel surcharge capped at 6%"]},
{"heading": "What the market charges", "bullets": ["$2.63-$2.75 per mile from the three nearest competitors", "Two competitors repriced in the last nine months", "No competitor caps fuel surcharge"]}
],
"source": "Source: broker rate benchmarks, Q2 2026; competitor tariff filings",
"notes": "Anticipate the objection that customers will leave; the market has already moved."
},
{
"type": "table",
"layout": "Title and Content",
"title": "A two-wave rollout captures 60% of the upside in the first two quarters",
"table": {
"columns": ["Wave", "Lanes", "Price change", "Margin impact", "Timing"],
"rows": [
["Wave 1", "6", "+12%", "+1.8 pts", "Q1-Q2 2027"],
["Wave 2", "6", "+9%", "+1.2 pts", "Q3-Q4 2027"],
["Total", "12", "+10.5%", "+3.0 pts", "18 months"]
],
"col_widths": [2, 1, 1.4, 1.6, 2],
"highlight_row": 2
},
"source": "Source: team pricing model; assumes 5% volume attrition on repriced lanes",
"notes": "The attrition assumption is conservative; the sensitivity is in the appendix."
},
{
"type": "quote",
"layout": "Quote",
"quote": "We would pay more on these lanes for guaranteed capacity. What we will not accept is surprise surcharges.",
"attribution": "VP Logistics, top-10 customer (interview, August 2026)",
"notes": "Close on the customer voice: price certainty matters more than price level."
}
]
}