
Beautiful Slides
- 2 installs
- Updated April 21, 2026
- bassimeledath/beautiful-slides
Beautiful Slides is a skill that produces editable, visually disciplined PowerPoint decks (.pptx) from a brief using python-pptx and LibreOffice.
About
Beautiful Slides produces editable PowerPoint decks (.pptx) from a written brief. It uses python-pptx for control and headless LibreOffice for rendering, then runs a bounds check and a must-include self-audit before declaring the deck done. A developer uses it when a task needs a slide deck, pitch, or read-ahead built or edited programmatically.
- Generates editable .pptx decks from a brief using python-pptx
- Enforces mood->mode selection, canvas-bounds check, and must-include self-audit
- Renders slides to PNG via LibreOffice for visual iteration
Beautiful Slides by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,166 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
beautiful-slides capabilities & compatibility
- Capabilities
- presentation generation · pptx authoring
- Use cases
- presentations
- Pricing
- Free
What beautiful-slides says it does
Produce editable, visually disciplined PowerPoint decks (.pptx) from a brief.
Uses python-pptx for fine control, LibreOffice for round-trip rendering, and enforces explicit mood→mode selection, a canvas-bounds check, and a must-include self-audit before declaring the deck done.
npx skills add https://github.com/bassimeledath/beautiful-slides --skill beautiful-slidesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | April 21, 2026 |
| Repository | bassimeledath/beautiful-slides ↗ |
What it does
Build or edit an editable PowerPoint deck from a brief with disciplined layout and a must-include audit.
Who is it for?
Building or editing editable slide decks, pitches, and read-ahead documents from a brief
Skip if: Plain-document work like essays, reports, blog posts, or emails unless a slide rendering is requested
When should I use this skill?
A task mentions a deck, slides, a presentation, a .pptx file, or asks to build/edit/inspect a slide deck
What you get
An editable .pptx where every slide's register is predictable, every shape stays in bounds, and every must-include is present
- editable .pptx deck
- per-slide PNG renders
- must-include audit checklist
By the numbers
- 8-step core workflow
- 16:9 canvas at 13.333 x 7.5 inches
Files
Beautiful Slides
A disciplined authoring skill for producing editable PowerPoint decks that pass a human "this looks intentional" bar. The target is not pretty-by-accident — it is a deck where every slide's register (palette, type, spacing) is predictable from the brief, every shape lives inside the canvas, and every must-include from the brief is actually present.
This skill is designed for an LLM that will write Python, execute it, inspect the rendered output, and iterate. It does not assume a GUI. It assumes Python 3, python-pptx, and a headless LibreOffice (soffice) are available.
Thesis
Say one thing, show one thing, prove one thing. One decision-relevant idea per narrative slide; evidence may be dense, but it must still point at one conclusion. A deck is not a mood board and not a memo — it is a memory machine. Story first, evidence second, appendix always.
Bullet points are not banned; they are on probation. They are defensible only when the audience needs a checklist, taxonomy, agenda, or parallel comparison — and only when the list is short enough to be scanned in one breath. In every other case, the bullet list is a failure of compression.
Live narrative decks and send-ahead read decks are different artifacts. A presenter-driven deck is a stage instrument; a read deck is a document with page logic, denser prose, and appendix-grade evidence. Trying to make one artifact do both produces the worst of both. If you need both, build two layers — a narrative deck in front, a reference appendix behind it.
When to use this skill
Trigger on any of:
- a
.pptxfile mentioned as input or output - the words "deck", "slides", "presentation", "pitch", "keynote", "read-ahead", "board", "launch"
- a request to build, edit, split, merge, summarise, or extract content from slides
- a request to produce a visual for a talk, a board meeting, a launch, or a dinner
Do not trigger for plain-document work (essays, reports, blog posts, emails) unless the user is explicitly asking for a slide rendering of that content.
Core workflow
A small, boring cycle. Do not deviate — the discipline is the feature.
1. Parse the brief. Extract: audience, length (exact slide count if given), mood cues, must-include items, do-not-do items. If any of these are missing, pick a reasonable default and note what you defaulted. 2. Pick one mode. Use the Mood → Mode table below. One mode, one palette, one type stance. Commit to it for every slide. 3. Write the outline. One line per slide: N. [pattern name] — [single message]. Do not proceed until the outline covers every must-include. A pitch-deck default cadence is listed under "Deck structure". 4. Author in Python. Use python-pptx. Create a new Presentation, set slide_width = Inches(13.333) and slide_height = Inches(7.5) for widescreen 16:9. Emit one slide at a time. Save. 5. Render to PNG. Use soffice --headless --convert-to pdf then pdftoppm -r 100 to turn the pptx into per-slide PNGs. Open slides 1, 2, and the final slide at minimum — do not skip this step. 6. Run the bounds check. python ${SKILL_DIR}/scripts/check_bounds.py out.pptx must exit 0. Fix any violation and re-save. 7. Run the must-include self-audit. Extract the brief's must-includes into a checklist, extract text from the pptx, and mark every item COVERED / MENTIONED / MISSING. Patch until all are COVERED. 8. Ship.
If any step fails, go back — do not paper over a violation by shrinking type to the point of unreadability or by dropping a must-include.
Mood → Mode Mapping
The generator's first job is to classify the deck's mood from the brief — scan for cues like "keynote", "board", "launch", "review", "docs", "conference", and adjectives such as "cinematic", "sober", "playful", "craft", or "technical". The second job is to pick exactly ONE mode from the table below that matches the dominant signal. The third job is to stay in that mode for every slide in the deck — palette, type stance, and layout stance all come from the same row.
| Mood cues in brief | Mode name | Palette / type stance | Layout stance |
|---|---|---|---|
| "Apple keynote", "cinematic", "OLED", "stage" | sv-keynote | near-black bg, off-white type, single electric accent, display serif OR grotesque at 84–120pt | full-bleed, asymmetric, one hero element |
| "board", "read-ahead", "Bain", "Economist" | consulting-boardroom | white bg, charcoal text, single muted accent, tabular figures | dense-but-aligned, takeaway titles, small type (14–18pt) |
| "New Yorker", "Monocle", "literary", "editorial" | editorial-magazine | warm paper bg, ink black, wine-red accent, serif display + serif body | hairlines, literate column rhythm, generous leading |
| "Kenya Hara", "Muji", "Kinfolk", "craft" | craft-minimal | warm off-white bg, near-black, optional muted olive/stone used once | huge margins, 1 image per slide, serif display as punctuation |
| "Figma Config", "Notion launch", "Linear launch", "playful" | playful-marketing | warm off-white + one orange-ish accent, custom grotesque display | oversize headlines, asymmetric but disciplined |
Authoring examples
- Brief says "Series B pitch on a big OLED, cinematic, confident, quiet, expensive" → pick
sv-keynote. Do NOT use gradients. Do NOT use 3 colors. Use ONE accent, black bg, single 120pt hero number on slide 2. - Brief says "internal board read-ahead for Q3 portfolio review, Economist-style, dense" → pick
consulting-boardroom. Do NOT use oversized display type. Do NOT use a dark bg. Use takeaway titles, 14–18pt body, one muted accent for the single KPI that matters.
Mode discipline (explicit ban)
Once a mode is picked, do not mix in elements from another mode. If the brief signals two modes, pick the dominant one from the LAST stated style sentence. A deck that is 70% sv-keynote and 30% editorial-magazine reads as inconsistent; a deck that is 100% sv-keynote reads as intentional, even if the mode choice is debatable. Consistency beats optimality.
Mode tokens
Recommended tokens per mode. Use these verbatim or tighten them — do not invent a fourth hue. The authoritative per-mode token dict (with full chart-compatible keys) lives in ${SKILL_DIR}/charts/MODE_TOKENS.md; the table below is the deck-authoring shorthand.
| Mode | bg | fg | muted | accent | display face | body face |
|---|---|---|---|---|---|---|
sv-keynote | #05070A | #F5F7FA | #9AA4B2 | #21D4FD | Manrope ExtraBold | Manrope Medium |
consulting-boardroom | #FFFFFF | #101828 | #475467 | #0F4C81 | Public Sans Semibold | Public Sans Regular |
editorial-magazine | #F6F1E8 | #181514 | #6F675F | #8C2F39 | Fraunces | Newsreader |
craft-minimal | #FCFBF8 | #22201C | #7B776F | #7C8571 | Instrument Serif | Instrument Sans |
playful-marketing | #FFF4EB | #1B1B1F | #6E6A73 | #FF7A00 | Bricolage Grotesque Bold | Plus Jakarta Sans Medium |
If a font is not installed, LibreOffice will silently substitute. Either install the font or switch the mode's face to something you know resolves — do not ship without verifying the rendered output.
Canvas, grid, and rhythm
The canvas is 16:9 widescreen: 13.333 in × 7.5 in (EMU: 12,192,000 × 6,858,000; 1 in = 914,400 EMU). The 1920 × 1080 px preview maps cleanly onto it. Every shape you place must fit inside this rectangle; the bounds check enforces that.
canvas-px: 1920 × 1080
canvas-in: 13.333 × 7.5
canvas-emu: 12192000 × 6858000
title-safe-margin-in: 0.667 left/right, 0.375 top/bottom
safe-area-in: 12.0 × 6.75
master-grid: 12 columns
gutter-in: 0.167
alias-grid-6: compare, paired charts, before/after
alias-grid-4: section dividers, three-up, hero slides
baseline-grid: 8 px / 4 pt / 50800 EMUThe 12-column grid is the operating system. Collapse to 6 for bilateral slides (before/after, counterweight). Collapse to 4 for hero slides, section breaks, or strict three-ups. Always land vertical breaks on a baseline — 8 px / 4 pt is fine enough for type, coarse enough for layout.
Spacing is a scale, not a vibe:
spacing-px: 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128Outer margins: 96 px on narrative slides, 64 px on dense reference slides. Gaps between related elements: 24–32 px. Gaps between unrelated groups: 48–64 px. Around the focal element, preserve at least 64 px of breathing room on every exposed side. If you cannot afford that, the slide has too many things on it.
Target ink ratios:
narrative-slides: 65/35 whitespace-to-ink
mixed-proof-slides: 55/45
chart-slides: 45/55
appendix-slides: 35/65Typography
We use exact steps, not formulas.
type-scale-pt: 112 / 84 / 60 / 44 / 32 / 24 / 18 / 14 / 12Pick the mode's scale from the token block above; use this global ladder when a specific mode value is not given.
Live body floors:
live-body-floor-pt:
small-room-30p: 24
auditorium-300p: 32
read-deck-laptop: 14Line-height: display (60–84 pt) 0.95; headline (32–44 pt) 1.05; subhead (24 pt) 1.15; body (14–18 pt) 1.35; caption (12 pt) 1.25.
Tracking (em thousandths): display -30, headline -15, subhead -5, body 0, caption +10, all-caps labels +40.
Measure (characters per line) should land in: display 12–24, headline 20–36, body 45–72, caption 28–42. If your body block is rendering at 90 CPL, shrink the block, not the type.
Numerals: tabular lining in tables, financials, dashboards, timelines, any column where alignment matters. Proportional lining in headlines and prose. Ban old-style numerals on projected slides — lovely in books, annoying at 40 feet.
A standard slide gets three typographic levels max: headline, support, annotation. A fourth level is allowed only in appendices, only for source notes. If you need five levels, you do not have hierarchy — you have sediment.
Color
One background, one primary text color, one accent. That is three colors total. If a chart needs more, use tints/shades of the accent plus one neutral grey. Never introduce a fourth hue.
Contrast floor: 7:1 for body text (WCAG AAA), 9:1 preferred for live presentation where projection, ambient light, tinted screens, and compressed exports degrade the signal. Hero text of 32 pt or above may fall to 7:1; body text does not get that luxury.
Dark mode is for cinematic keynotes, product demos on controlled displays, and stage environments where the screen itself is a light source. Most SaaS decks should not default to dark mode because they are read on laptops, forwarded as PDFs, printed, and skimmed in bright rooms. Choose dark only when the brief says "stage", "OLED", or "cinematic".
Colorblind-safe pairs: blue/orange, navy/gold, teal/magenta, charcoal/acid green — but only when the luminance contrast is strong. Avoid red/green as a sole encoding; never use color as the only means of conveying meaning. Run a grayscale check routinely.
Deck structure
Structures are not interchangeable. Use the pattern that matches the job.
| Structure | One-sentence summary | Best-fit contexts |
|---|---|---|
| SCQA | Situation, complication, question, answer. | Executive updates, consulting decks, board decks. |
| Minto Pyramid | Lead with the recommendation, group supporting evidence. | Boardroom, investor diligence. |
| Problem–Agitate–Solve | Make the pain vivid, then relieve it. | Sales, category creation, launches. |
| Duarte Sparkline | Alternate "what is" and "what could be" to create tension. | Keynotes, product launches, mission talks. |
| Heroic Arc | Audience is hero; presenter is guide. | Vision decks, fundraising with a founder story. |
Default cadence for a strong 10-slide pitch deck: 1. Company purpose. 2. Problem (sharp customer pain). 3. Product / solution. 4. Why now. 5. Market / wedge. 6. Traction. 7. Business model. 8. Go-to-market. 9. Team. 10. Vision + raise / next milestone.
A 5-slide executive update: status headline, KPI delta, risk callout, decision needed, next 30/60/90. If your update has a history lesson, it is not an update.
A 30-slide technical talk: opener → stakes → current-state pain → mental model → architecture → demo slice → evidence → edge cases → benchmark → migration path → lessons → recap → Q&A, with appendices after the live narrative.
Slide patterns
A grammar, not a template library. Each pattern defines what the slide is for, when to use it, composition bounds, and copy limits. Compose from these; do not invent new ones on the fly.
Marquee — announce the deck. Opener only. Title 60–84 pt, 2–8 words; subtitle 18 pt, 0–16 words.
Knife-Cut — section divider. 1–4 words at 44–60 pt. No sentence, no punctuation. 96 px top/bottom space.
Verdict — one memorable claim. Headline 44–60 pt, 3–12 words, verbs, no period. Proof line 18 pt, ≤20 words.
Pullquote — borrow authority. Quote 32–44 pt, 8–40 words, max 36 CPL. Attribution 14 pt, name, role. One quote only.
Number Hammer — make one number impossible to forget. Hero numeral 84–120 pt. Unit/context 24 pt, ≤12 words. Explainer 14–18 pt, ≤20 words. One small source note in a corner.
Counterweight — compare two states. 6-col alias, split at center. Each side: one 24 pt heading, one 14–18 pt body, ≤3 evidence chips. ≤40 words per side.
Triptych — three parallel ideas. 4-col alias, one unit per third, shared baseline. Titles 1–3 words, body ≤24 words. Syntax must be parallel. No filled tiles — use spacing, not containers.
Proof Plot — quantitative evidence with a takeaway. Chart 8 cols, takeaway block 4 cols. Takeaway headline 24 pt, 4–10 words. Source line required. Remove non-data ink.
System Map — relationships, flows, architecture. 12-col grid, max 7 nodes on narrative slides (12 in appendix). Labels 14–18 pt, 1–4 words per node. Arrows move in one dominant direction.
Runway — sequence over time. Horizontal for 3–7 stages. Step label 18–24 pt, 1–3 words. Description 12–14 pt, ≤16 words. One time axis, one direction.
Poster — let an image do the emotional labor. Full-bleed photo; text in title-safe area with one light/dark scrim if needed. Headline 32–60 pt, 1–8 words.
Crop Demo — show product UI without drowning in chrome. Crop aggressively; one zoomed region per slide. Callout 18 pt, caption 14 pt. Never paste a full browser screenshot unless the browser matters.
Resolution — closing. CTA 32–44 pt, imperative verb, 2–10 words. No "Thank you" as primary. If there are three next steps, use Triptych instead.
Dossier — appendix, board backup. 12-col grid, 64 px margins. Headline 24 pt, body 12–14 pt, tabular lining. Citations mandatory. Up to 120 words body.
Holding Screen — Q&A. One label 44 pt, optional prompt 18 pt. Low-noise background. No decorative montage.
python-pptx cheat sheet
Minimal imports and constants the generator should reach for:
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
BLANK = prs.slide_layouts[6] # truly blankA full-bleed background rectangle for dark modes:
slide = prs.slides.add_slide(BLANK)
bg = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, prs.slide_height
)
bg.fill.solid(); bg.fill.fore_color.rgb = RGBColor(0x05, 0x07, 0x0A)
bg.line.fill.background()A text block (do this instead of placeholders — placeholders drag layout baggage you will fight):
tb = slide.shapes.add_textbox(Inches(0.5), Inches(0.6), Inches(12.3), Inches(1.2))
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = 0
tf.margin_top = tf.margin_bottom = 0
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
run = p.add_run()
run.text = "Halverline"
run.font.name = "Manrope"
run.font.size = Pt(44)
run.font.color.rgb = RGBColor(0xF5, 0xF7, 0xFA)A Number Hammer (slide 2 or 3 of most sv-keynote decks):
hero = slide.shapes.add_textbox(Inches(1.0), Inches(2.2), Inches(11.3), Inches(3.2))
htf = hero.text_frame
r = htf.paragraphs[0].add_run()
r.text = "$18.4M"
r.font.size = Pt(220)
r.font.name = "Manrope"
r.font.bold = True
r.font.color.rgb = RGBColor(0xF5, 0xF7, 0xFA)Accent line (a thin rectangle beats .line for consistency):
accent = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
Inches(0.5), Inches(5.9), Inches(0.6), Emu(38000) # ~3 pt tall
)
accent.fill.solid(); accent.fill.fore_color.rgb = RGBColor(0x21, 0xD4, 0xFD)
accent.line.fill.background()Charts: use the bundled ${SKILL_DIR}/charts/ pack. It ships five native python-pptx templates (bar, line, kpi, funnel, heatmap), each exposing render(slide, data, tokens, bounds). Pass the same mode tokens you picked in the Mood → Mode step — charts must share the deck's palette and type. If your chart type is not in the pack, add a new template under ${SKILL_DIR}/charts/<type>/ following the shared signature. Do not rasterize a chart to PNG and add_picture it — that path is explicitly out of scope.
Speaker notes (always fill them — the deck is a stage instrument, the notes are the script):
slide.notes_slide.notes_text_frame.text = "ARR hero. Pause. Let the number land."Data visualization
The shipped ${SKILL_DIR}/charts/ pack is the only supported path for charts on a slide. It ships five native python-pptx templates:
1. bar — discrete comparisons across categories ("which bucket wins"). Grouped 2-series supported. 2. line — trends over ordered x (time, cohort age). 1–N series; last series can be emphasized. 3. kpi — hero-number tile with label, value, delta. Compose 2–4 in a row for a scorecard. 4. funnel — conversion / narrowing stages (pipeline, signup drop-off). 4–7 stages. 5. heatmap — grid of colored cells, intensity between bg and primary (hour × weekday, segment × month). Best at ≥ 4×4.
If the chart you need is not one of these, add a new template under ${SKILL_DIR}/charts/<type>/ following the same render(slide, data, tokens, bounds) signature. Do not reach for matplotlib or any PNG-rasterization detour.
Banned on slides: dual-axis charts, 3D bars, pies with more than three slices, stacked area with more than four series, radar charts, donut charts with decorative holes pretending to be insight.
Direct-label the data, not the legend, whenever possible. If you exceed five distinct series, split into small multiples or kill series until the chart has a voice again. Apply Tufte's data-ink ratio aggressively — any redundant gridline, legend box, axis title, border, bevel, or default shadow is theft. If the graphic can lose 30% of its non-data ink without losing meaning, remove it.
A stat-hero slide is not a chart. Use the kpi template, or a plain Number Hammer pattern — one number, one context line, one comparator if truly necessary, one source. Do not wrap a hero number in axes or a lonely bar because software offered to.
Imagery and icons
Use SVG for logos, icons, diagrams, and anything with text or sharp edges. Use PNG for raster interface captures. Use photos for evidence, atmosphere, human context. Use illustration only when reality is unavailable or distracting.
Icon hierarchy: Lucide first, Heroicons second, Phosphor third, Radix fourth. Keep icons on a 24 × 24 grid when they appear in slides. Match stroke weights. Never mix filled and outline icons unless the semantic distinction is structural and repeated.
AI-generated images are defensible only in concept / speculative / internal decks, and only when labeled honestly. For investor-facing or public-facing decks, treat them as guilty until verified. Known fast tells of AI sludge: extra or fused fingers, garbled signage, over-smoothed faces, default cyan-teal grade, plastic bokeh, symmetrical interiors.
Do not use stock photography of diverse smiling teams around a MacBook. Do not use business-handshake-in-front-of-glass-tower imagery. Do not use glowing-brains-made-of-circuits. Do not use pastel isometric collaborators leaning over giant charts. If you would find it on page one of a template marketplace, bury it.
Motion and builds
Default: no transition between standard slides; fade between major sections if the deck is live and cinematic. Nothing else by default. Motion must guide attention or clarify sequence; if it does neither, it is pageantry.
The only three defensible builds: 1. Fade In — labels, callouts, progressive evidence. 0.20–0.35 s. 2. Wipe — process steps, timelines, directional sequences. Direction must match narrative flow. 3. Morph / matched-move — only for zooming into the same object state across slides (chart, map, UI crop). Never a magic trick.
Forbidden transitions by name: Cube, Rotate, Fly-In From Corners, Bounce, Dissolve With Sparkle, Random Bars, Curtains, Ferris Wheel.
Rendering and visual QA
Always render before declaring done. Headless:
soffice --headless --convert-to pdf --outdir /tmp/render out.pptx
pdftoppm -r 100 /tmp/render/out.pdf /tmp/render/slideThis produces /tmp/render/slide-01.png, -02.png, etc. Open slides 1, 2, and the last one at minimum. Look for:
- text clipped off the canvas edges
- overlap between title and body blocks
- wrong font silently substituted (LibreOffice falls back when a font is missing)
- invisible text (same color as background)
- charts pushed off-canvas by an oversize legend
If any of these is present, fix in Python and re-render. Do not ship on a first render without visually checking.
Post-generation Bounds Check
After saving, run the bundled script:
python ${SKILL_DIR}/scripts/check_bounds.py path/to/out.pptxIt walks every shape on every slide and asserts (left, top, width, height) stays inside 12,192,000 × 6,858,000 EMU. Exits 0 if clean; exits 1 with a report of violations otherwise. Example violation line:
slide=4 shape=TextBox 12 bounds=(11800000,6200000,1200000,1000000) violation=right>canvas,bottom>canvasThe fix is always: move or resize the shape. Do not "solve" a bounds violation by cropping mentally — LibreOffice renders clip as silent truncation and the judge sees it.
Re-run the check after every patch until it exits 0.
Must-Include Self-Audit (run AFTER saving the pptx)
Before declaring the deck complete, extract every must-include bullet from the scenario brief into an explicit checklist. Run markitdown on the emitted pptx (python -m markitdown output.pptx) and, for each checklist item, grep/scan the output text. For each item mark: COVERED (literal phrase or close paraphrase is present), MENTIONED (referenced but vague), or MISSING.
If any item is MISSING, patch the deck: pick the best slide to add/modify, make the change, re-save, re-run this audit. Repeat until every item is COVERED. MENTIONED items should be upgraded to COVERED when feasible.
Worked example:
- [COVERED] ARR $18.4M — slide 3 hero number
- [MENTIONED] 94% gross margin — slide 4 footnote, not called out
- [MISSING] Top 5 logos — NOT PRESENT, must add as slide 7Do NOT skip this step even if the deck looks good. Visual polish does not substitute for brief fidelity.
Editing an existing deck
When the user hands you a .pptx and asks for an edit, do not rebuild from scratch. Open it with python-pptx, inspect prs.slides, modify the specific shapes you need, and save to a new path. Preserve the original's layout masters, slide dimensions, and theme colors — those are what make the deck look "like itself".
Inspection:
from pptx import Presentation
prs = Presentation("in.pptx")
for i, slide in enumerate(prs.slides, 1):
for s in slide.shapes:
if s.has_text_frame:
text = s.text_frame.text.replace("\n", " | ")
print(f"slide {i} shape {s.shape_id} {s.name!r}: {text[:80]}")Bulk text replacement:
for slide in prs.slides:
for s in slide.shapes:
if not s.has_text_frame:
continue
for p in s.text_frame.paragraphs:
for r in p.runs:
r.text = r.text.replace("Q3'25", "Q4'25")Always re-run the bounds check after an edit — a longer string can push a text frame past the canvas.
Anti-patterns (refuse these)
Named so they stop coming back.
- Bullet Brigade. Seven or more bullets in identical verb+explanation syntax. Collapse to a Verdict, Triptych, or Dossier.
- Template Tyranny. Every slide identical layout, title top-left + three objects below. Rotate among patterns intentionally.
- Gradient Industrial Complex. Purple-to-blue gradient on every background. Use flat backgrounds; if you need drama, use image, contrast, or scale.
- Emoji HR Department. Emojis standing in for icons. Replace with a proper icon family or drop the icon.
- Isometric Interns. Pastel isometric people collaborating around abstract dashboards. Use a real product screenshot or nothing.
- Cardocalypse. Every fact lives in a floating rounded card with a soft shadow. Separate by spacing and alignment; remove containers.
- Blob Nebula. Decorative blobs, meshes, dot grids behind content. Flat background or one relevant image.
- Lorem Ipsum's Revenge. Placeholder text shipped. Grep for lorem/ipsum/placeholder/sample before saving.
- Period Piece. Every headline is a full sentence with a period. Convert to takeaway phrases with verbs and no periods.
- Synergy Index. "Solutions", "leveraging", "synergies", "best-in-class", "robust", "seamless". Replace each with a concrete noun, number, or action.
- Excel Aftertaste. Default blue charts, default legend, default gridlines. Restyle from scratch, add a takeaway title, label directly.
- Font Soup. Three or more unrelated typefaces on one slide. One display, one body, one mono maximum.
- Teleprompter Leakage. The slide text is the speech text. Move prose to notes; leave the skeleton.
- Polite Shrug. Final slide says "Thank You" and nothing else. Replace with Resolution — one ask, one next step.
- Laptop Diversity Theatre. Stock photo of smiling diverse team around a laptop. Use product imagery or nothing.
- Shadow Government. Every box, chart, image gets a drop shadow. Reserve shadows only for separating inset imagery from a same-tone background.
- Icon Civil War. Flat icons mixed with skeuomorphic. One family, one rendering logic.
- Centerfold Paragraph. Long centered body copy. Left-align body; reserve centered alignment for short ceremonial text.
- Geist Everywhere. Default contemporary sans plus generic near-black UI styling plus blue accent. Choose a mode on purpose. Typography is a position, not a default.
Accessibility
WCAG AAA is the floor. 7:1 contrast for body, 9:1 preferred for live. Colorblind-safe pairs above; color never the only encoding. Minimum live font sizes: 24 pt body in small rooms, 32 pt in large rooms, 14 pt in read decks, 12 pt only for citations. For read decks, every non-decorative figure gets alt text: [what it is] + [the takeaway] + [critical value or trend] + [timeframe/source].
Low-tech test that catches half the sins software misses: squint at the slide from six feet away. If the focal hierarchy disappears, the slide is wrong. If you cannot explain what is first, second, and third to read, the slide is wrong.
Dependencies
- Python 3.10+
python-pptx—pip install python-pptxmarkitdown—pip install markitdown(for the must-include self-audit)- LibreOffice with a headless
sofficebinary (macOS:brew install --cask libreoffice; Linux: package manager) pdftoppmfrom poppler (macOS:brew install poppler; Linux:poppler-utils)
If a fonts directory is available, point LibreOffice at it so your Manrope / Public Sans / Inter calls resolve; otherwise expect silent fallback to a default sans.
Charts
Reach for a chart when the evidence is the story — a revenue bridge, a retention cohort, a conversion funnel, a dashboard-scorecard of KPIs, a density grid. Do not reach for a chart to decorate a narrative slide; a Number Hammer or direct-labeled table usually wins. When the chart is the slide, use the bundled ${SKILL_DIR}/charts/ pack: five native python-pptx templates, theme-aware, load-bearing.
The five templates and when to use them:
- bar — discrete comparisons across categories ("which bucket wins"). Grouped 2-series supported.
- line — trends over ordered x (time, cohort age). 1–N series; last series can be emphasized.
- kpi — hero-number tile with label, value, delta. Compose 2–4 in a row for a scorecard.
- funnel — conversion / narrowing stages (pipeline, signup drop-off). 4–7 stages.
- heatmap — grid of colored cells, intensity between
bgandprimary(hour × weekday, segment × month). Best at ≥ 4×4.
Every template exposes exactly one function with the same signature:
def render(slide, data, tokens, bounds):
"""Draw chart onto `slide` inside `bounds` (x, y, w, h in EMU), styled by `tokens`."""tokens is a dict with keys primary, accent, text, muted, bg, font_display, font_body, font_mono, font_size_base_pt, radius_px. Pull these from the mode you picked in the Mood → Mode step — charts must use the same palette and type as the rest of the deck. Never hardcode a color inside a chart call.
See ${SKILL_DIR}/charts/INDEX.md for the full interface, ${SKILL_DIR}/charts/MODE_TOKENS.md for exact token dicts per mode, and each template's docs.md + example.py for the data shape. Rendered per-mode previews live in ${SKILL_DIR}/charts/<template>/renders/*.png.
Charts in this pack are native python-pptx shapes — rectangles, lines, textboxes — not rasterized PNGs. They stay editable in PowerPoint, they scale without blur, and they pass check_bounds.py. Do not wrap them in a matplotlib add_picture detour.
File layout in this skill
${SKILL_DIR}/
SKILL.md # this file
scripts/
check_bounds.py # canvas-bounds enforcer (run after save)
render_preview.py # soffice + pdftoppm convenience wrapper
charts/ # five themed chart templates (bar, line, kpi, funnel, heatmap)
INDEX.md # shared signature + picker guide
INTERFACE.md # authoritative interface reference
MODE_TOKENS.md # per-mode token dicts
tokens.py # shared MODES dict imported by every example.pyClosing
A deck is good when the audience understands the point before you explain it, remembers it after you leave, and can defend it when someone tougher than you asks for evidence. When in doubt: if the slide still makes sense after you remove one-third of what is on it, you have not finished editing. Say one thing, show one thing, prove one thing.
bar chart
Native python-pptx bar chart. Supports vertical and horizontal orientations, single-series and grouped (2-series) forms.
When to use
- Discrete comparisons across a small number of categories (3–12).
- Showing actuals vs. plan, or two related series side-by-side (grouped).
- When the emphasis is on ranking/magnitude rather than trend over time.
When not to use
- Time series with many points → use a line chart.
- Part-to-whole where categories sum to a meaningful total → use a stacked bar or donut.
- More than ~12 categories → the labels crowd.
Data shape
data = {
"orientation": "vertical", # "vertical" or "horizontal"
"title": "Revenue by segment, Q1", # optional; may be None
"categories": ["Enterprise", "Mid-market", "SMB", "Startup"],
"series": [
{"name": "Q1 Actual", "values": [12.4, 8.1, 4.3, 1.8]},
{"name": "Q1 Plan", "values": [11.0, 8.5, 5.0, 2.0]}, # optional
],
"value_suffix": "M", # optional; appended to each value label
"show_values": True, # optional; default True
}- Up to 2 series. First series uses
tokens["primary"], second usestokens["accent"]. - When 2 series are provided, a small legend is drawn at the top-right.
Example
from pptx import Presentation
from pptx.util import Inches, Emu
from charts.bar.render import render
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
slide = prs.slides.add_slide(prs.slide_layouts[6])
tokens = {
"primary": "#0F4C81", "accent": "#05603A", "text": "#101828",
"muted": "#475467", "bg": "#FFFFFF",
"font_display": "Public Sans", "font_body": "Public Sans",
"font_mono": "Public Sans", "font_size_base_pt": 14, "radius_px": 0,
}
data = {
"orientation": "vertical",
"title": "Revenue by segment, Q1",
"categories": ["Enterprise", "Mid-market", "SMB", "Startup"],
"series": [{"name": "Q1 Actual", "values": [12.4, 8.1, 4.3, 1.8]}],
"value_suffix": "M", "show_values": True,
}
m = Inches(0.5)
bounds = (m, m, prs.slide_width - 2 * m, prs.slide_height - 2 * m)
render(slide, data, tokens, bounds)
prs.save("example.pptx")"""Render the bar chart in all 5 modes as separate 16:9 slides."""
import sys
from pathlib import Path
from pptx import Presentation
from pptx.util import Inches
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tokens import MODES # noqa: E402
from render import render
DATA_SINGLE = {
"orientation": "vertical",
"title": "Revenue by segment, Q1",
"categories": ["Enterprise", "Mid-market", "SMB", "Startup"],
"series": [
{"name": "Q1 Actual", "values": [12.4, 8.1, 4.3, 1.8]},
],
"value_suffix": "M",
"show_values": True,
}
DATA_GROUPED = {
"orientation": "vertical",
"title": "Q1 actual vs. plan, by segment",
"categories": ["Enterprise", "Mid-market", "SMB", "Startup"],
"series": [
{"name": "Actual", "values": [12.4, 8.1, 4.3, 1.8]},
{"name": "Plan", "values": [11.0, 8.5, 5.0, 2.0]},
],
"value_suffix": "M",
"show_values": True,
}
DATA_HORIZONTAL = {
"orientation": "horizontal",
"title": "Revenue by segment, Q1",
"categories": ["Enterprise", "Mid-market", "SMB", "Startup"],
"series": [
{"name": "Q1 Actual", "values": [12.4, 8.1, 4.3, 1.8]},
],
"value_suffix": "M",
"show_values": True,
}
# Pair each mode with a data variant so we exercise grouped + horizontal forms.
MODE_DATA = {
"sv-keynote": DATA_SINGLE,
"editorial-magazine": DATA_HORIZONTAL,
"playful-marketing": DATA_GROUPED,
"consulting-boardroom": DATA_GROUPED,
"craft-minimal": DATA_SINGLE,
}
def main():
out_dir = Path(__file__).parent
for mode, tokens in MODES.items():
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
slide = prs.slides.add_slide(prs.slide_layouts[6])
margin = Inches(0.5)
bounds = (
margin,
margin,
prs.slide_width - 2 * margin,
prs.slide_height - 2 * margin,
)
data = MODE_DATA[mode]
render(slide, data, tokens, bounds)
out = out_dir / f"example-{mode}.pptx"
prs.save(str(out))
print(f"wrote {out.name}")
if __name__ == "__main__":
main()
"""Bar chart renderer — native python-pptx shapes only.
Public API: render(slide, data, tokens, bounds)
"""
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.util import Emu, Pt
# EMU constants
_EMU_PER_PX = 9525 # 96 DPI reference
_EMU_PER_PT = 12700
def _rgb(hex_):
return RGBColor.from_string(hex_.lstrip("#"))
def _set_fill(shape, hex_):
shape.fill.solid()
shape.fill.fore_color.rgb = _rgb(hex_)
def _no_fill(shape):
shape.fill.background()
def _set_line(shape, hex_, width_emu=None):
shape.line.color.rgb = _rgb(hex_)
if width_emu is not None:
shape.line.width = Emu(int(width_emu))
def _no_line(shape):
shape.line.fill.background()
def _style_run(run, font_name, size_pt, hex_color, bold=False):
run.font.name = font_name
run.font.size = Pt(size_pt)
run.font.color.rgb = _rgb(hex_color)
run.font.bold = bold
def _add_text(slide, x, y, w, h, text, font_name, size_pt, hex_color,
bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.MIDDLE):
tb = slide.shapes.add_textbox(Emu(int(x)), Emu(int(y)), Emu(int(w)), Emu(int(h)))
tf = tb.text_frame
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
tf.word_wrap = True
tf.vertical_anchor = anchor
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
_style_run(run, font_name, size_pt, hex_color, bold=bold)
return tb
def _add_bar(slide, x, y, w, h, fill_hex, radius_px):
w_i = max(1, int(w))
h_i = max(1, int(h))
if radius_px and radius_px > 0:
shape = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE, Emu(int(x)), Emu(int(y)), Emu(w_i), Emu(h_i)
)
# adjustment is a ratio of the short side
short_emu = min(w_i, h_i)
radius_emu = radius_px * _EMU_PER_PX
ratio = max(0.0, min(0.5, radius_emu / short_emu / 2.0))
try:
shape.adjustments[0] = ratio
except Exception:
pass
else:
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Emu(int(x)), Emu(int(y)), Emu(w_i), Emu(h_i)
)
_set_fill(shape, fill_hex)
_no_line(shape)
return shape
def _add_line(slide, x1, y1, x2, y2, hex_color, width_emu):
ln = slide.shapes.add_connector(1, Emu(int(x1)), Emu(int(y1)), Emu(int(x2)), Emu(int(y2)))
ln.line.color.rgb = _rgb(hex_color)
ln.line.width = Emu(int(width_emu))
return ln
def _nice_ticks(vmax, target=5):
if vmax <= 0:
return [0, 1], 1
import math
raw = vmax / target
magnitude = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1
residual = raw / magnitude
if residual < 1.5:
step = 1 * magnitude
elif residual < 3:
step = 2 * magnitude
elif residual < 7:
step = 5 * magnitude
else:
step = 10 * magnitude
top = step * math.ceil(vmax / step)
ticks = []
v = 0.0
while v <= top + 1e-9:
ticks.append(v)
v += step
return ticks, top
def _fmt(v, suffix=""):
if abs(v - round(v)) < 1e-9:
s = f"{int(round(v))}"
else:
s = f"{v:.1f}"
return f"{s}{suffix}" if suffix else s
def render(slide, data, tokens, bounds):
x, y, w, h = bounds
orientation = (data.get("orientation") or "vertical").lower()
title = data.get("title")
categories = list(data.get("categories") or [])
series = list(data.get("series") or [])
value_suffix = data.get("value_suffix") or ""
show_values = bool(data.get("show_values", True))
if not categories or not series:
return
# Pad to at most 2 series
series = series[:2]
bg = tokens["bg"]
primary = tokens["primary"]
accent = tokens["accent"]
text_c = tokens["text"]
muted = tokens["muted"]
font_display = tokens["font_display"]
font_body = tokens["font_body"]
font_mono = tokens["font_mono"]
base_pt = int(tokens["font_size_base_pt"])
radius_px = int(tokens.get("radius_px") or 0)
# Background
bg_shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Emu(int(x)), Emu(int(y)), Emu(int(w)), Emu(int(h)))
_set_fill(bg_shape, bg)
_no_line(bg_shape)
# Outer padding
pad = int(min(w, h) * 0.04)
ix = x + pad
iy = y + pad
iw = w - 2 * pad
ih = h - 2 * pad
title_h = 0
if title:
title_pt = int(round(base_pt * 1.6))
title_h = int(Pt(title_pt).emu * 1.8)
_add_text(
slide, ix, iy, iw, title_h, title,
font_name=font_display, size_pt=title_pt, hex_color=text_c,
bold=True, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
)
iy += title_h + int(Pt(base_pt).emu * 0.6)
ih = (y + h - pad) - iy
series_colors = [primary, accent][: len(series)]
# Determine value range
all_vals = []
for s in series:
all_vals.extend(s.get("values") or [])
if not all_vals:
return
vmax_raw = max(all_vals)
ticks, vmax = _nice_ticks(max(vmax_raw, 0.0))
# Legend (only if >=2 series)
legend_h = 0
if len(series) >= 2:
legend_pt = max(int(base_pt * 0.85), 8)
legend_h = int(Pt(legend_pt).emu * 2.0)
_draw_legend(slide, ix, iy, iw, legend_h, series, series_colors,
font_body, legend_pt, text_c, muted, radius_px)
iy += legend_h
ih -= legend_h
tick_pt = max(int(base_pt * 0.78), 8)
cat_pt = max(int(base_pt * 0.92), 9)
val_pt = max(int(base_pt * 0.78), 8)
if orientation == "horizontal":
_draw_horizontal(
slide, ix, iy, iw, ih, categories, series, series_colors,
ticks, vmax, text_c, muted, font_body, font_mono,
cat_pt, tick_pt, val_pt, radius_px, value_suffix, show_values,
)
else:
_draw_vertical(
slide, ix, iy, iw, ih, categories, series, series_colors,
ticks, vmax, text_c, muted, font_body, font_mono,
cat_pt, tick_pt, val_pt, radius_px, value_suffix, show_values,
)
def _draw_legend(slide, x, y, w, h, series, colors, font_body, pt, text_c, muted, radius_px):
# Right-aligned legend: [sw] name [sw] name
swatch = int(Pt(pt).emu * 0.9)
gap = int(Pt(pt).emu * 0.4)
item_gap = int(Pt(pt).emu * 1.2)
# Measure approximate widths
widths = []
for s, _c in zip(series, colors):
name = s.get("name") or ""
est_text_w = int(Pt(pt).emu * 0.55 * max(len(name), 1))
widths.append(swatch + gap + est_text_w)
total = sum(widths) + item_gap * (len(widths) - 1)
cursor = x + max(0, w - total)
cy = y + (h - swatch) // 2
ty = y
for (s, c), wd in zip(zip(series, colors), widths):
sw = slide.shapes.add_shape(MSO_SHAPE.OVAL, Emu(int(cursor)), Emu(int(cy)), Emu(swatch), Emu(swatch))
_set_fill(sw, c)
_no_line(sw)
tx = cursor + swatch + gap
tw = wd - swatch - gap
_add_text(
slide, tx, ty, tw, h, s.get("name") or "",
font_name=font_body, size_pt=pt, hex_color=text_c,
align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.MIDDLE,
)
cursor += wd + item_gap
def _draw_vertical(slide, x, y, w, h, categories, series, colors, ticks, vmax,
text_c, muted, font_body, font_mono, cat_pt, tick_pt, val_pt,
radius_px, suffix, show_values):
# Reserve space for y-axis tick labels on left, and category labels below
# Longest tick label drives left margin
tick_labels = [_fmt(t) for t in ticks]
max_tick_chars = max(len(s) for s in tick_labels) if tick_labels else 1
left_margin = int(Pt(tick_pt).emu * 0.65 * (max_tick_chars + 1))
bottom_margin = int(Pt(cat_pt).emu * 2.2)
top_margin = int(Pt(val_pt).emu * 1.6) # headroom for value labels
right_margin = int(Pt(val_pt).emu * 1.2)
plot_x = x + left_margin
plot_y = y + top_margin
plot_w = max(1, w - left_margin - right_margin)
plot_h = max(1, h - top_margin - bottom_margin)
hairline = max(int(_EMU_PER_PX * 0.5), 3175)
# Gridlines for tick values (light)
for i, t in enumerate(ticks):
ty = plot_y + plot_h - int(plot_h * (t / vmax)) if vmax > 0 else plot_y + plot_h
# y-axis tick label
lbl_w = left_margin - int(Pt(tick_pt).emu * 0.3)
lbl_h = int(Pt(tick_pt).emu * 1.4)
_add_text(
slide, x, ty - lbl_h // 2, lbl_w, lbl_h,
_fmt(t) + (suffix if t != 0 and i == len(ticks) - 1 else ""),
font_name=font_mono, size_pt=tick_pt, hex_color=muted,
align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE,
)
# hairline gridline across plot (skip zero which becomes baseline)
if i > 0:
_add_line(slide, plot_x, ty, plot_x + plot_w, ty, muted, hairline)
# Baseline (axis)
base_y = plot_y + plot_h
_add_line(slide, plot_x, base_y, plot_x + plot_w, base_y, muted, hairline * 2)
n_cat = len(categories)
n_series = len(series)
group_w = plot_w / n_cat
inner_pad = group_w * 0.18
bars_region = group_w - 2 * inner_pad
bar_gap = bars_region * 0.08 if n_series > 1 else 0
bar_w = (bars_region - bar_gap * (n_series - 1)) / n_series
for ci, cat in enumerate(categories):
group_left = plot_x + ci * group_w
# Category label
cat_y = base_y + int(Pt(cat_pt).emu * 0.4)
cat_h = int(Pt(cat_pt).emu * 1.6)
_add_text(
slide, group_left, cat_y, group_w, cat_h, str(cat),
font_name=font_body, size_pt=cat_pt, hex_color=text_c,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.TOP,
)
for si, (s, color) in enumerate(zip(series, colors)):
vals = s.get("values") or []
if ci >= len(vals):
continue
v = vals[ci]
bh = int(plot_h * (max(v, 0) / vmax)) if vmax > 0 else 0
bx = group_left + inner_pad + si * (bar_w + bar_gap)
by = base_y - bh
if bh > 0:
_add_bar(slide, bx, by, bar_w, bh, color, radius_px)
if show_values:
vt = _fmt(v, suffix)
vh = int(Pt(val_pt).emu * 1.3)
_add_text(
slide, bx, by - vh - int(Pt(val_pt).emu * 0.1),
bar_w, vh, vt,
font_name=font_mono, size_pt=val_pt, hex_color=text_c,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM,
)
def _draw_horizontal(slide, x, y, w, h, categories, series, colors, ticks, vmax,
text_c, muted, font_body, font_mono, cat_pt, tick_pt, val_pt,
radius_px, suffix, show_values):
# Category labels on left; bars extend right; tick labels on bottom
max_cat_chars = max((len(str(c)) for c in categories), default=4)
left_margin = int(Pt(cat_pt).emu * 0.55 * (max_cat_chars + 2))
bottom_margin = int(Pt(tick_pt).emu * 2.0)
top_margin = int(Pt(val_pt).emu * 0.6)
right_margin = int(Pt(val_pt).emu * 3.0) # room for bar value labels
plot_x = x + left_margin
plot_y = y + top_margin
plot_w = max(1, w - left_margin - right_margin)
plot_h = max(1, h - top_margin - bottom_margin)
hairline = max(int(_EMU_PER_PX * 0.5), 3175)
# x-axis ticks (bottom)
for i, t in enumerate(ticks):
tx = plot_x + int(plot_w * (t / vmax)) if vmax > 0 else plot_x
tlh = int(Pt(tick_pt).emu * 1.4)
_add_text(
slide, tx - int(Pt(tick_pt).emu * 2), plot_y + plot_h + int(Pt(tick_pt).emu * 0.3),
int(Pt(tick_pt).emu * 4), tlh,
_fmt(t) + (suffix if t != 0 and i == len(ticks) - 1 else ""),
font_name=font_mono, size_pt=tick_pt, hex_color=muted,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.TOP,
)
if i > 0:
_add_line(slide, tx, plot_y, tx, plot_y + plot_h, muted, hairline)
# Baseline (left axis)
_add_line(slide, plot_x, plot_y, plot_x, plot_y + plot_h, muted, hairline * 2)
n_cat = len(categories)
n_series = len(series)
row_h = plot_h / n_cat
inner_pad = row_h * 0.18
bars_region = row_h - 2 * inner_pad
bar_gap = bars_region * 0.08 if n_series > 1 else 0
bar_h = (bars_region - bar_gap * (n_series - 1)) / n_series
for ci, cat in enumerate(categories):
row_top = plot_y + ci * row_h
# Category label on left, vertically centered on the row
cat_h_em = int(Pt(cat_pt).emu * 1.4)
_add_text(
slide, x, row_top + (row_h - cat_h_em) / 2,
left_margin - int(Pt(cat_pt).emu * 0.4), cat_h_em,
str(cat),
font_name=font_body, size_pt=cat_pt, hex_color=text_c,
align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE,
)
for si, (s, color) in enumerate(zip(series, colors)):
vals = s.get("values") or []
if ci >= len(vals):
continue
v = vals[ci]
bw = int(plot_w * (max(v, 0) / vmax)) if vmax > 0 else 0
by = row_top + inner_pad + si * (bar_h + bar_gap)
bx = plot_x
if bw > 0:
_add_bar(slide, bx, by, bw, bar_h, color, radius_px)
if show_values:
vt = _fmt(v, suffix)
vh = int(Pt(val_pt).emu * 1.3)
vw_em = int(Pt(val_pt).emu * 4)
_add_text(
slide, bx + bw + int(Pt(val_pt).emu * 0.3),
by + (bar_h - vh) / 2, vw_em, vh, vt,
font_name=font_mono, size_pt=val_pt, hex_color=text_c,
align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.MIDDLE,
)
funnel
Vertical conversion funnel drawn with native freeform trapezoids.
render(slide, data, tokens, bounds)
data
{
"title": "Enterprise pipeline, Q1", # optional
"stages": [
{"label": "Leads", "value": 10000},
{"label": "Qualified", "value": 4200},
# ...
],
"show_conversion": True, # show % between stages
"value_format": "{:,}", # format string for values
}Stages render top-down, widest to narrowest. Widths are proportional to each stage's value, normalized so the widest stage = ~98% of the inner funnel column and the narrowest last stage >= ~18% of that.
Style
- Each trapezoid is a 4-point freeform (TL, TR, BR, BL) so the bottom
width of stage i matches the top width of stage i+1 — the funnel is continuous.
- Fill color interpolates linearly in sRGB from
tokens["primary"]at
the top to tokens["muted"] at the bottom.
- Stage labels: left column,
font_body,tokens["text"], right-aligned. - Stage values: right column,
font_mono,tokens["text"], bold,
left-aligned, formatted via value_format.
- Conversion %: between trapezoids,
font_mono,tokens["muted"],
small (~0.75× base).
- Title (optional): top-left,
font_display, 1.5× base, bold. - No outlines on trapezoids. No hardcoded colors or fonts.
Bounds
The chart fills (x, y, w, h) exactly: title at top, then the funnel with its left/right label gutters.
Proof
python example.py writes example-<mode>.pptx at the chart root, one per mode.
import os
import sys
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.util import Emu, Inches
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.dirname(HERE)) # charts/ for shared tokens module
from tokens import MODES # noqa: E402
from render import render, _rgb # noqa: E402
DATA = {
"title": "Enterprise pipeline, Q1",
"stages": [
{"label": "Leads", "value": 10000},
{"label": "Qualified", "value": 4200},
{"label": "Demo'd", "value": 1800},
{"label": "Proposal", "value": 620},
{"label": "Negotiation", "value": 310},
{"label": "Closed-won", "value": 180},
],
"show_conversion": True,
"value_format": "{:,}",
}
def _set_slide_bg(slide, hex_color):
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = RGBColor.from_string(hex_color.lstrip("#"))
def build(mode_name, tokens, out_path):
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
slide = prs.slides.add_slide(blank)
_set_slide_bg(slide, tokens["bg"])
margin = Inches(0.6)
x = int(margin)
y = int(margin)
w = int(prs.slide_width) - 2 * int(margin)
h = int(prs.slide_height) - 2 * int(margin)
render(slide, DATA, tokens, (x, y, w, h))
prs.save(out_path)
def main():
for name, tokens in MODES.items():
path = os.path.join(HERE, f"example-{name}.pptx")
build(name, tokens, path)
print(f"wrote {path}")
if __name__ == "__main__":
main()
from pptx.dml.color import RGBColor
from pptx.util import Emu, Pt
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
def _rgb(hex_):
return RGBColor.from_string(hex_.lstrip("#"))
def _lerp_hex(h1, h2, t):
h1 = h1.lstrip("#")
h2 = h2.lstrip("#")
r1, g1, b1 = int(h1[0:2], 16), int(h1[2:4], 16), int(h1[4:6], 16)
r2, g2, b2 = int(h2[0:2], 16), int(h2[2:4], 16), int(h2[4:6], 16)
r = round(r1 + (r2 - r1) * t)
g = round(g1 + (g2 - g1) * t)
b = round(b1 + (b2 - b1) * t)
return f"#{r:02X}{g:02X}{b:02X}"
def _add_textbox(slide, x, y, w, h, text, font_name, size_pt, color_hex,
align=PP_ALIGN.LEFT, bold=False, anchor=MSO_ANCHOR.MIDDLE):
tb = slide.shapes.add_textbox(Emu(x), Emu(y), Emu(w), Emu(h))
tf = tb.text_frame
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
tf.word_wrap = True
tf.vertical_anchor = anchor
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.name = font_name
run.font.size = Pt(size_pt)
run.font.bold = bold
run.font.color.rgb = _rgb(color_hex)
return tb
def _draw_trapezoid(slide, tl_x, tl_y, tr_x, bl_x, br_x, bottom_y, fill_hex):
ff = slide.shapes.build_freeform(int(tl_x), int(tl_y), scale=1.0)
ff.add_line_segments(
[
(int(tr_x), int(tl_y)),
(int(br_x), int(bottom_y)),
(int(bl_x), int(bottom_y)),
],
close=True,
)
shape = ff.convert_to_shape()
shape.fill.solid()
shape.fill.fore_color.rgb = _rgb(fill_hex)
shape.line.fill.background()
return shape
def render(slide, data, tokens, bounds):
x, y, w, h = bounds
primary = tokens["primary"]
muted = tokens["muted"]
text_c = tokens["text"]
font_display = tokens["font_display"]
font_body = tokens["font_body"]
font_mono = tokens["font_mono"]
base_pt = tokens["font_size_base_pt"]
stages = data.get("stages", [])
if not stages:
return
title = data.get("title")
show_conversion = data.get("show_conversion", True)
value_format = data.get("value_format", "{:,}")
n = len(stages)
max_val = max(s["value"] for s in stages) or 1
# Layout regions
cur_y = y
pad = Emu(Pt(6)).emu if False else int(Pt(6)) # not used directly; keep simple
title_h = 0
if title:
title_h = int(Pt(base_pt * 1.6) * 1.8)
_add_textbox(
slide,
x, cur_y, w, title_h,
title, font_display, int(base_pt * 1.5), text_c,
align=PP_ALIGN.LEFT, bold=True, anchor=MSO_ANCHOR.TOP,
)
cur_y += title_h + int(Pt(base_pt * 0.6))
# Reserve label columns on left and right
label_col_w = int(w * 0.22)
value_col_w = int(w * 0.18)
gap_labels = int(w * 0.015)
funnel_x = x + label_col_w + gap_labels
funnel_w = w - label_col_w - value_col_w - 2 * gap_labels
if funnel_w < int(w * 0.3):
funnel_w = int(w * 0.3)
label_col_w = int((w - funnel_w) * 0.55)
value_col_w = int((w - funnel_w) * 0.45) - 2 * gap_labels
funnel_x = x + label_col_w + gap_labels
funnel_center = funnel_x + funnel_w // 2
max_funnel_w = int(funnel_w * 0.98)
min_funnel_w = max(int(max_funnel_w * 0.18), int(Emu(Pt(8)).emu))
# Vertical layout: n stages + (n-1) gaps; if show_conversion, the gap holds text
avail_h = (y + h) - cur_y
inter_gap = int(Pt(base_pt * (1.6 if show_conversion else 0.55)))
total_gaps = inter_gap * (n - 1)
stage_h = max(int((avail_h - total_gaps) / n), int(Pt(base_pt * 1.4)))
def width_for(val):
t = val / max_val
return int(min_funnel_w + (max_funnel_w - min_funnel_w) * t)
stage_y = cur_y
prev_bottom_w = None
for i, stage in enumerate(stages):
# Top width = width for this stage's value
top_w = width_for(stage["value"])
# Bottom width = width for next stage (or slightly narrower if last)
if i < n - 1:
bot_w = width_for(stages[i + 1]["value"])
else:
bot_w = max(int(top_w * 0.45), min_funnel_w // 2)
tl_x = funnel_center - top_w // 2
tr_x = funnel_center + top_w // 2
bl_x = funnel_center - bot_w // 2
br_x = funnel_center + bot_w // 2
tl_y = stage_y
bottom_y = stage_y + stage_h
t = i / (n - 1) if n > 1 else 0.0
fill_hex = _lerp_hex(primary, muted, t)
_draw_trapezoid(slide, tl_x, tl_y, tr_x, bl_x, br_x, bottom_y, fill_hex)
# Label on left
label_x = x
label_w = label_col_w
_add_textbox(
slide,
label_x, stage_y, label_w, stage_h,
stage["label"], font_body, base_pt, text_c,
align=PP_ALIGN.RIGHT, bold=False, anchor=MSO_ANCHOR.MIDDLE,
)
# Value on right
val_x = x + label_col_w + gap_labels + funnel_w + gap_labels
val_w = (x + w) - val_x
if val_w < int(w * 0.1):
val_w = int(w * 0.1)
val_text = value_format.format(stage["value"])
_add_textbox(
slide,
val_x, stage_y, val_w, stage_h,
val_text, font_mono, base_pt, text_c,
align=PP_ALIGN.LEFT, bold=True, anchor=MSO_ANCHOR.MIDDLE,
)
# Conversion between this stage and next
if show_conversion and i < n - 1:
next_val = stages[i + 1]["value"]
if stage["value"] > 0:
pct = (next_val / stage["value"]) * 100
else:
pct = 0
conv_text = f"{pct:.0f}% \u2193"
conv_y = bottom_y
conv_h = inter_gap
_add_textbox(
slide,
funnel_x, conv_y, funnel_w, conv_h,
conv_text, font_mono, max(int(base_pt * 0.75), 8), muted,
align=PP_ALIGN.CENTER, bold=False, anchor=MSO_ANCHOR.MIDDLE,
)
stage_y = bottom_y + inter_gap
return None
heatmap
Grid of colored cells filling bounds. Each cell's fill is linearly interpolated between tokens["bg"] and tokens["primary"] by the normalized value — so every mode produces its own gradient family (black→cyan, cream→wine, peach→orange, white→navy, off-white→sage).
API
render(slide, data, tokens, bounds)Data shape
data = {
"title": "Usage intensity by hour and weekday", # optional
"row_labels": ["Mon", "Tue", ...],
"col_labels": ["00", "02", ...],
"values": [[0.1, 0.1, ...], ...], # row-major
"value_min": 0.0, # optional, auto from values
"value_max": 1.0, # optional, auto from values
"show_values": False, # print formatted value in each cell
"value_format": "{:.0%}", # python format string
}Layout
- Title at top (if provided),
font_display,text. - Column labels across the top row,
font_body,muted, ~0.75× base size. - Row labels on the left, right-aligned,
font_body,muted. - Cell grid fills remaining area; hairline gap + hairline
mutedoutline at 0.25pt. - Cell fill:
lerp(bg, primary, normalized_value). Normalized value clamped to [0, 1]. radius_pxhonored viaMSO_SHAPE.ROUNDED_RECTANGLE.- If
show_values: formatted value centered in each cell,font_mono, auto-chosen color (light vs dark) based on cell luminance relative tobg. - Legend strip bottom-right: 24-step gradient + min/max labels in
font_mono/muted.
Tokens used
primary, text, muted, bg, font_display, font_body, font_mono, font_size_base_pt, radius_px.
Proof
python example.py writes example-<mode>.pptx at the chart root, one per mode. Passed.
import os
import sys
from pptx import Presentation
from pptx.util import Emu, Inches
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.dirname(HERE)) # charts/ for shared tokens module
from tokens import MODES # noqa: E402
from render import render, _rgb # noqa: E402
def _data():
rows = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
cols = ["00", "02", "04", "06", "08", "10", "12", "14", "16", "18", "20", "22"]
base = [
[0.08, 0.05, 0.03, 0.03, 0.18, 0.55, 0.72, 0.85, 0.88, 0.78, 0.60, 0.35],
[0.07, 0.04, 0.03, 0.04, 0.22, 0.58, 0.76, 0.90, 0.92, 0.82, 0.62, 0.40],
[0.09, 0.06, 0.04, 0.05, 0.24, 0.60, 0.80, 0.94, 0.96, 0.86, 0.66, 0.45],
[0.10, 0.06, 0.04, 0.04, 0.26, 0.62, 0.82, 0.95, 0.97, 0.88, 0.70, 0.48],
[0.14, 0.10, 0.06, 0.06, 0.28, 0.58, 0.78, 0.88, 0.84, 0.80, 0.72, 0.62],
[0.22, 0.18, 0.12, 0.08, 0.16, 0.32, 0.50, 0.58, 0.64, 0.72, 0.80, 0.78],
[0.25, 0.20, 0.14, 0.09, 0.14, 0.28, 0.42, 0.50, 0.54, 0.60, 0.66, 0.58],
]
return {
"title": "Usage intensity — weekday × hour",
"row_labels": rows,
"col_labels": cols,
"values": base,
"value_min": 0.0,
"value_max": 1.0,
"show_values": True,
"value_format": "{:.0%}",
}
def _make_pptx(mode_name, tokens, data, out_dir):
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
slide = prs.slides.add_slide(blank)
bg_shape = slide.shapes.add_shape(
1,
0, 0,
prs.slide_width, prs.slide_height,
)
bg_shape.shadow.inherit = False
bg_shape.fill.solid()
bg_shape.fill.fore_color.rgb = _rgb(tokens["bg"])
bg_shape.line.fill.background()
bg_shape.text_frame.text = ""
margin = Inches(0.6)
bounds = (
margin,
margin,
prs.slide_width - 2 * margin,
prs.slide_height - 2 * margin,
)
render(slide, data, tokens, bounds)
out_path = os.path.join(out_dir, f"example-{mode_name}.pptx")
prs.save(out_path)
return out_path
def main():
data = _data()
written = []
for name, tokens in MODES.items():
path = _make_pptx(name, tokens, data, HERE)
written.append(path)
print(f"wrote {path}")
print(f"OK: {len(written)} files")
if __name__ == "__main__":
main()
from pptx.util import Emu, Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
def _rgb(hex_):
return RGBColor.from_string(hex_.lstrip("#"))
def _lerp_hex(h1, h2, t):
t = max(0.0, min(1.0, t))
h1 = h1.lstrip("#")
h2 = h2.lstrip("#")
r1, g1, b1 = int(h1[0:2], 16), int(h1[2:4], 16), int(h1[4:6], 16)
r2, g2, b2 = int(h2[0:2], 16), int(h2[2:4], 16), int(h2[4:6], 16)
r = round(r1 + (r2 - r1) * t)
g = round(g1 + (g2 - g1) * t)
b = round(b1 + (b2 - b1) * t)
return f"#{r:02X}{g:02X}{b:02X}"
def _luminance(hex_):
h = hex_.lstrip("#")
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _set_text(tf, text, font_name, size_pt, color_hex, bold=False, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE):
tf.word_wrap = True
tf.margin_left = Emu(0)
tf.margin_right = Emu(0)
tf.margin_top = Emu(0)
tf.margin_bottom = Emu(0)
tf.vertical_anchor = anchor
p = tf.paragraphs[0]
p.alignment = align
if p.runs:
for r in list(p.runs):
r.text = ""
run = p.add_run()
run.text = str(text)
run.font.name = font_name
run.font.size = Pt(size_pt)
run.font.bold = bold
run.font.color.rgb = _rgb(color_hex)
def _add_rect(slide, x, y, w, h, fill_hex, line_hex=None, line_w_emu=0, radius_emu=0):
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius_emu > 0 else MSO_SHAPE.RECTANGLE
shp = slide.shapes.add_shape(shape_type, Emu(int(x)), Emu(int(y)), Emu(int(w)), Emu(int(h)))
if radius_emu > 0:
try:
short = min(w, h)
adj = max(0.0, min(0.5, (radius_emu / short)))
shp.adjustments[0] = adj
except Exception:
pass
shp.shadow.inherit = False
shp.fill.solid()
shp.fill.fore_color.rgb = _rgb(fill_hex)
if line_hex is None or line_w_emu <= 0:
shp.line.fill.background()
else:
shp.line.color.rgb = _rgb(line_hex)
shp.line.width = Emu(int(line_w_emu))
shp.text_frame.text = ""
tf = shp.text_frame
tf.margin_left = Emu(0)
tf.margin_right = Emu(0)
tf.margin_top = Emu(0)
tf.margin_bottom = Emu(0)
return shp
def _add_textbox(slide, x, y, w, h, text, font_name, size_pt, color_hex, bold=False, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE):
tb = slide.shapes.add_textbox(Emu(int(x)), Emu(int(y)), Emu(int(w)), Emu(int(h)))
_set_text(tb.text_frame, text, font_name, size_pt, color_hex, bold=bold, align=align, anchor=anchor)
return tb
def render(slide, data, tokens, bounds):
x, y, w, h = bounds
row_labels = list(data.get("row_labels", []))
col_labels = list(data.get("col_labels", []))
values = data.get("values", [])
title = data.get("title")
show_values = bool(data.get("show_values", False))
value_format = data.get("value_format", "{:.2f}")
n_rows = len(row_labels)
n_cols = len(col_labels)
if n_rows == 0 or n_cols == 0 or not values:
return None
flat = [v for row in values for v in row]
vmin = data.get("value_min")
vmax = data.get("value_max")
if vmin is None:
vmin = min(flat) if flat else 0.0
if vmax is None:
vmax = max(flat) if flat else 1.0
if vmax == vmin:
vmax = vmin + 1.0
bg_hex = tokens["bg"]
primary_hex = tokens["primary"]
text_hex = tokens["text"]
muted_hex = tokens["muted"]
font_body = tokens["font_body"]
font_display = tokens["font_display"]
font_mono = tokens["font_mono"]
base_pt = int(tokens["font_size_base_pt"])
radius_px = int(tokens.get("radius_px") or 0)
radius_emu = radius_px * 9525
label_pt = max(7, int(round(base_pt * 0.72)))
title_pt = max(base_pt + 2, int(round(base_pt * 1.15)))
cell_value_pt = max(6, int(round(base_pt * 0.55)))
legend_pt = max(6, int(round(base_pt * 0.6)))
title_h = 0
if title:
title_h = int(Pt(title_pt * 1.6))
legend_h = int(Pt(base_pt * 2.2))
row_label_w = int(Pt(base_pt * 2.6))
col_label_h = int(Pt(base_pt * 1.4))
for lbl in row_labels:
est = int(Pt(base_pt * 0.55 * max(3, len(str(lbl)))))
if est > row_label_w:
row_label_w = est
row_label_w = min(row_label_w, int(w * 0.22))
inner_x = x
inner_y = y + title_h
inner_w = w
inner_h = h - title_h - legend_h
grid_x = inner_x + row_label_w
grid_y = inner_y + col_label_h
grid_w = inner_w - row_label_w
grid_h = inner_h - col_label_h
gap = max(1, int(Emu(0.5 * 9525)))
cell_w = (grid_w - gap * (n_cols - 1)) / n_cols
cell_h = (grid_h - gap * (n_rows - 1)) / n_rows
if title:
_add_textbox(
slide,
x, y, w, title_h,
title,
font_display,
title_pt,
text_hex,
bold=True,
align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP,
)
for ci, cl in enumerate(col_labels):
cx = grid_x + ci * (cell_w + gap)
_add_textbox(
slide,
int(cx), int(inner_y), int(cell_w), int(col_label_h),
cl,
font_body,
label_pt,
muted_hex,
align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE,
)
for ri, rl in enumerate(row_labels):
ry = grid_y + ri * (cell_h + gap)
pad_right = int(Pt(base_pt * 0.3))
_add_textbox(
slide,
int(inner_x), int(ry), int(row_label_w - pad_right), int(cell_h),
rl,
font_body,
label_pt,
muted_hex,
align=PP_ALIGN.RIGHT,
anchor=MSO_ANCHOR.MIDDLE,
)
for ri in range(n_rows):
row = values[ri] if ri < len(values) else []
for ci in range(n_cols):
v = row[ci] if ci < len(row) else vmin
try:
t = (float(v) - float(vmin)) / (float(vmax) - float(vmin))
except Exception:
t = 0.0
t = max(0.0, min(1.0, t))
fill_hex = _lerp_hex(bg_hex, primary_hex, t)
cx = grid_x + ci * (cell_w + gap)
cy = grid_y + ri * (cell_h + gap)
_add_rect(
slide,
cx, cy, cell_w, cell_h,
fill_hex,
line_hex=muted_hex,
line_w_emu=int(Emu(0.25 * 9525)),
radius_emu=radius_emu,
)
if show_values:
try:
vtxt = value_format.format(v)
except Exception:
vtxt = str(v)
cell_lum = _luminance(fill_hex)
bg_lum = _luminance(bg_hex)
if bg_lum < 0.5:
label_color = text_hex if cell_lum < 0.55 else bg_hex
else:
label_color = bg_hex if cell_lum < 0.45 else text_hex
_add_textbox(
slide,
int(cx), int(cy), int(cell_w), int(cell_h),
vtxt,
font_mono,
cell_value_pt,
label_color,
align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE,
)
legend_y = inner_y + inner_h
legend_w = int(min(inner_w * 0.35, Pt(base_pt * 14)))
legend_x = x + w - legend_w
legend_bar_h = int(Pt(base_pt * 0.55))
legend_bar_y = legend_y + int(Pt(base_pt * 0.3))
steps = 24
step_w = legend_w / steps
for i in range(steps):
t = i / (steps - 1) if steps > 1 else 0.0
fill_hex = _lerp_hex(bg_hex, primary_hex, t)
sx = legend_x + i * step_w
_add_rect(
slide,
sx, legend_bar_y, step_w + Emu(1), legend_bar_h,
fill_hex,
line_hex=None,
line_w_emu=0,
radius_emu=0,
)
try:
min_lbl = value_format.format(vmin)
max_lbl = value_format.format(vmax)
except Exception:
min_lbl = f"{vmin}"
max_lbl = f"{vmax}"
lbl_y = legend_bar_y + legend_bar_h + int(Pt(base_pt * 0.15))
lbl_h = int(Pt(legend_pt * 1.4))
_add_textbox(
slide,
int(legend_x), int(lbl_y), int(legend_w / 2), lbl_h,
min_lbl,
font_mono,
legend_pt,
muted_hex,
align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP,
)
_add_textbox(
slide,
int(legend_x + legend_w / 2), int(lbl_y), int(legend_w / 2), lbl_h,
max_lbl,
font_mono,
legend_pt,
muted_hex,
align=PP_ALIGN.RIGHT,
anchor=MSO_ANCHOR.TOP,
)
return None
Charts — index
Native python-pptx chart templates. Theme-aware via tokens. Five templates share one signature.
Shared signature — load-bearing
Every chart exposes EXACTLY one public function:
def render(slide, data, tokens, bounds):
"""Draw the chart onto `slide` inside `bounds`, styled by `tokens`."""slide— apptx.slide.Slideinstance. Shapes are added toslide.shapes.data— chart-type-specific dict. See each chart'sdocs.mdfor its shape.tokens— theme dict; keys below.bounds—(x_emu, y_emu, w_emu, h_emu). Chart fits inside.
Token contract
All colors are hex strings like "#RRGGBB". All fonts are family-name strings.
| Key | Type | Role |
|---|---|---|
primary | str | main series / hero color |
accent | str | secondary series / highlights |
text | str | foreground / labels |
muted | str | axes, gridlines, secondary labels |
bg | str | background fill |
font_display | str | titles and hero numbers |
font_body | str | labels |
font_mono | str | numeric alignment (may equal body) |
font_size_base_pt | int | body size in points |
radius_px | int | optional corner radius in px |
Never hardcode a color or font in a chart file. Read everything from tokens.
EMU coordinate system
python-pptx works in English Metric Units. Handy:
914400EMU per inch12700EMU per point- Use
pptx.util.Emu(n),pptx.util.Inches(n),pptx.util.Pt(n)helpers
bounds is always (x, y, w, h) in EMUs. A common 16:9 slide is 12192000 × 6858000 EMU.
Picker guide
- bar — discrete comparisons across categories (revenue by segment, satisfaction by team). Use when the story is "which bucket wins." Supports grouped 2-series.
- line — trends over ordered x (time, months, cohort age). Use for cohort retention, trajectories, forecasts. Supports 1–N series; final series can be emphasized.
- kpi — hero-number tile with label, value, delta. The single most-used element. Compose 2–4 tiles in a row to build a scorecard.
- funnel — conversion / narrowing stages (lead → closed-won). Best for pipeline, signup flows, drop-off stories. 4–7 stages.
- heatmap — grid of colored cells, intensity interpolates between
bgandprimary. Use for density patterns (hour × weekday, segment × month). Best at ≥ 4×4 cells.
Import pattern (inside render.py)
Every chart sticks to this small set:
from pptx.util import Emu, Pt, Inches
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHORNo external styling libraries. No rasterizers. No embedded Excel charts.
Using a chart from a deck script
from pptx import Presentation
from pptx.util import Inches, Emu
import sys; sys.path.append("/path/to/charts")
from bar.render import render as render_bar
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
slide = prs.slides.add_slide(prs.slide_layouts[6])
tokens = {
"primary": "#0F4C81", "accent": "#05603A", "text": "#101828",
"muted": "#475467", "bg": "#FFFFFF",
"font_display": "Public Sans", "font_body": "Public Sans", "font_mono": "Public Sans",
"font_size_base_pt": 14, "radius_px": 0,
}
data = {"orientation": "vertical",
"categories": ["Enterprise", "Mid-market", "SMB", "Startup"],
"series": [{"name": "Q1 Actual", "values": [12.4, 8.1, 4.3, 1.8]}],
"value_suffix": "M", "show_values": True}
bounds = (Inches(0.8), Inches(0.8), Inches(11.7), Inches(5.9))
bounds = tuple(int(x) for x in bounds)
render_bar(slide, data, tokens, bounds)
prs.save("deck.pptx")Directory layout
charts/
INDEX.md ← this file
bar/ render.py docs.md example.py example-<mode>.pptx×5 renders/<mode>.png×5
line/ render.py docs.md example.py example-<mode>.pptx×5 renders/<mode>.png×5
kpi/ render.py docs.md example.py example-<mode>.pptx×5 renders/<mode>.png×5
funnel/ render.py docs.md example.py example-<mode>.pptx×5 renders/<mode>.png×5
heatmap/ render.py docs.md example.py example-<mode>.pptx×5 renders/<mode>.png×5The five modes are: sv-keynote, editorial-magazine, playful-marketing, consulting-boardroom, craft-minimal. Each mode has a distinct palette, type system, and spacing temperament — see MODE_TOKENS.md at the project root for exact token dicts.
Chart Interface Contract — load-bearing
Every chart file MUST expose EXACTLY one public function:
def render(slide, data, tokens, bounds):
"""
Render this chart type into the given slide using native python-pptx shapes.
Parameters
----------
slide : pptx.slide.Slide
The target slide. Shapes are added to slide.shapes.
data : dict or list
Chart-type-specific data payload. See the chart's docs.md for shape.
tokens : dict
Theme tokens. Keys (all required): primary, accent, text, muted, bg,
font_display, font_body, font_mono, font_size_base_pt.
Optional: radius_px.
All colors are hex strings "#RRGGBB". Fonts are family-name strings.
bounds : tuple
(x_emu, y_emu, w_emu, h_emu). The chart must fit inside this rectangle.
"""Rules
- Native python-pptx shapes ONLY. No pptxgenjs, no SVG, no embedded pptx chart types, no matplotlib-to-image.
- Acceptable imports:
pptx.util,pptx.shapes.*,pptx.dml.color,pptx.enum.shapes,pptx.enum.text. Stdlib fine. tokensis the ONLY styling source. Never hardcode a color or font anywhere.- Read colors as hex strings; convert with
pptx.dml.color.RGBColor.from_string(hex[1:])(skip the#). - Use
pptx.util.Emu(n)/pptx.util.Pt(n)for coordinate math. - The chart fills
bounds. Anything outsideboundsis a bug. - No network calls. No file I/O inside
render(). - The function mutates
slideand returns None (or the root shape group, optional).
Example color/font pattern
from pptx.dml.color import RGBColor
from pptx.util import Emu, Pt
def _rgb(hex_):
return RGBColor.from_string(hex_.lstrip("#"))
def render(slide, data, tokens, bounds):
x, y, w, h = bounds
bg = _rgb(tokens["bg"])
primary = _rgb(tokens["primary"])
font_body = tokens["font_body"]
size = Pt(tokens["font_size_base_pt"])
# ... build shapes ...Forbidden
- Hardcoded hex literals (except inside the
_rgbhelper above, which only reads fromtokens). - Hardcoded font names.
matplotlib,PIL, any rasterizer.pptx.chart.*(the built-in chart API) — we want native drawn shapes, not embedded Excel charts.- Drawing outside
bounds.
KPI tile
Big-number tile with label, value, delta, and optional footnote. One call = one tile; callers compose rows/grids themselves.
render(slide, data, tokens, bounds)
data
{
"label": "ARR",
"value": "$47.2M", # pre-formatted hero string
"delta": "+12.4% vs plan", # optional
"delta_direction": "up", # "up" | "down" | None
"footnote": "Source: NetSuite, Apr 10", # optional
}Behavior
- Tile is a rounded rect (or sharp if
radius_px == 0) filled withbg, hairline border inmuted. - Label:
font_body, uppercased,muted, ~0.78 × base. - Value:
font_display,text, sized fromboundsheight (capped at 96pt, shrunk to fit width). - Dark modes (
sv-keynote) get a short neon underline inprimaryunder the value. - Delta:
font_mono.up→accentwith ▲;down→muted(orprimarywhere it reads as attention without clashing) with ▼. - Footnote:
font_body,muted, ~0.7 × base, anchored near the bottom of the tile.
Constraints
- Draws entirely inside
bounds. - All colors/fonts come from
tokens. No hardcoded hex or font names. - Native python-pptx shapes only.
Proof
python example.py → five example-<mode>.pptx files render without errors.
"""Render KPI tiles in all 5 modes. Generates one .pptx per mode."""
import os
import sys
from pptx import Presentation
from pptx.util import Emu, Inches
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.dirname(HERE)) # charts/ for shared tokens module
from tokens import MODES # noqa: E402
from render import render, _rgb # noqa: E402
from pptx.enum.shapes import MSO_SHAPE # noqa: E402
TILES = [
{
"label": "ARR",
"value": "$47.2M",
"delta": "+12.4% vs plan",
"delta_direction": "up",
"footnote": "Source: NetSuite, Apr 10",
},
{
"label": "Gross Margin",
"value": "72.8%",
"delta": "-1.1 pts QoQ",
"delta_direction": "down",
"footnote": "Non-GAAP, ex. one-time",
},
{
"label": "NPS",
"value": "64",
"delta": "+6 vs Q4",
"delta_direction": "up",
"footnote": "n=1,842, mixed segment",
},
]
def _paint_slide_bg(slide, hex_):
bg = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, 0, 0,
slide.part.package.presentation_part.presentation.slide_width,
slide.part.package.presentation_part.presentation.slide_height,
)
bg.fill.solid()
bg.fill.fore_color.rgb = _rgb(hex_)
bg.line.fill.background()
# Send to back.
spTree = bg._element.getparent()
spTree.remove(bg._element)
spTree.insert(2, bg._element)
def build(mode, tokens):
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
_paint_slide_bg(slide, tokens["bg"])
sw = prs.slide_width
sh = prs.slide_height
# Layout: 3 tiles side-by-side.
margin_x = int(sw * 0.06)
margin_y_top = int(sh * 0.22)
gutter = int(sw * 0.03)
tile_area_w = sw - 2 * margin_x
tile_w = (tile_area_w - 2 * gutter) // 3
tile_h = int(sh * 0.48)
for i, tile in enumerate(TILES):
bx = margin_x + i * (tile_w + gutter)
by = margin_y_top
render(slide, tile, tokens, (bx, by, tile_w, tile_h))
out = os.path.join(HERE, f"example-{mode}.pptx")
prs.save(out)
return out
def main():
for mode, tokens in MODES.items():
path = build(mode, tokens)
print(f"wrote {path}")
if __name__ == "__main__":
main()
"""KPI tile — big-number, label, delta, footnote."""
from pptx.util import Emu, Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
def _rgb(hex_):
return RGBColor.from_string(hex_.lstrip("#"))
_LIGHT_BG_HEXES = {"F6F1E8", "FFF4EB", "FFFFFF", "FCFBF8"}
def _is_dark_mode(bg_hex):
return bg_hex.lstrip("#").upper() not in _LIGHT_BG_HEXES
def _add_textbox(slide, x, y, w, h, text, *, font, size_pt, color_hex,
bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
letter_spacing=None):
tb = slide.shapes.add_textbox(Emu(int(x)), Emu(int(y)), Emu(int(w)), Emu(int(h)))
tf = tb.text_frame
tf.margin_left = Emu(0)
tf.margin_right = Emu(0)
tf.margin_top = Emu(0)
tf.margin_bottom = Emu(0)
tf.word_wrap = True
tf.vertical_anchor = anchor
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
f = run.font
f.name = font
f.size = Pt(size_pt)
f.bold = bold
f.color.rgb = _rgb(color_hex)
return tb
def render(slide, data, tokens, bounds):
x, y, w, h = bounds
label = data.get("label", "")
value = data.get("value", "")
delta = data.get("delta")
direction = data.get("delta_direction")
footnote = data.get("footnote")
bg_hex = tokens["bg"]
text_hex = tokens["text"]
muted_hex = tokens["muted"]
primary_hex = tokens["primary"]
accent_hex = tokens["accent"]
font_display = tokens["font_display"]
font_body = tokens["font_body"]
font_mono = tokens["font_mono"]
base_pt = tokens["font_size_base_pt"]
radius_px = tokens.get("radius_px", 0) or 0
dark = _is_dark_mode(bg_hex)
# Tile background — subtle rounded rect with hairline border.
radius_emu = Emu(int(radius_px * 9525)) # px → EMU (approx 9525 EMU/px)
if radius_px > 0:
tile = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Emu(int(x)), Emu(int(y)),
Emu(int(w)), Emu(int(h)))
# python-pptx rounded-rect adjustment is relative (0.0–~0.5).
try:
short_side = min(w, h)
adj = max(0.0, min(0.5, float(radius_emu) / float(short_side)))
tile.adjustments[0] = adj
except Exception:
pass
else:
tile = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
Emu(int(x)), Emu(int(y)),
Emu(int(w)), Emu(int(h)))
tile.fill.solid()
tile.fill.fore_color.rgb = _rgb(bg_hex)
tile.line.color.rgb = _rgb(muted_hex)
tile.line.width = Emu(6350) # 0.5 pt hairline
# Remove default shadow.
try:
sp = tile.shadow
sp.inherit = False
except Exception:
pass
# Padding inside tile.
pad_x = int(w * 0.07)
pad_y = int(h * 0.09)
inner_x = x + pad_x
inner_y = y + pad_y
inner_w = w - 2 * pad_x
inner_h = h - 2 * pad_y
# Label (top, small caps-ish via font).
label_size = max(8, int(base_pt * 0.78))
label_h = Pt(label_size).emu * 1.5
_add_textbox(slide, inner_x, inner_y, inner_w, label_h,
label.upper() if label else "",
font=font_body, size_pt=label_size,
color_hex=muted_hex, bold=True, align=PP_ALIGN.LEFT)
# Value — hero number. Size based on tile height.
value_pt_from_h = (h / 12700.0) * 0.22
value_pt = max(36, min(int(value_pt_from_h), 96))
# Shrink if the string is long relative to width.
est_char_w_emu = Pt(value_pt).emu * 0.55
est_w = est_char_w_emu * max(1, len(value))
if est_w > inner_w:
scale = inner_w / est_w
value_pt = max(28, int(value_pt * scale))
value_h = Pt(value_pt).emu * 1.2
value_y = inner_y + label_h + Pt(base_pt * 0.35).emu
_add_textbox(slide, inner_x, value_y, inner_w, value_h,
value,
font=font_display, size_pt=value_pt,
color_hex=text_hex, bold=True, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP)
# Neon underline under the hero number in dark mode (sv-keynote signature).
if dark:
underline_w = int(inner_w * 0.18)
underline_h = Emu(int(Pt(3).emu))
underline_y = value_y + value_h + Pt(base_pt * 0.15).emu
ul = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
Emu(int(inner_x)), Emu(int(underline_y)),
Emu(int(underline_w)), underline_h)
ul.fill.solid()
ul.fill.fore_color.rgb = _rgb(primary_hex)
ul.line.fill.background()
else:
underline_y = value_y + value_h
underline_h = 0
# Delta — under the value.
delta_y = (underline_y + (Pt(base_pt * 0.45).emu if dark else Pt(base_pt * 0.25).emu)
+ (int(underline_h) if dark else 0))
if delta:
if direction == "up":
delta_color = accent_hex
glyph = "\u25B2 " # ▲
elif direction == "down":
delta_color = primary_hex if not dark else accent_hex
# If primary is the same hue family as up-accent we already got,
# fall back to muted to signal "attention" without clashing.
if delta_color == accent_hex:
delta_color = muted_hex
glyph = "\u25BC " # ▼
else:
delta_color = muted_hex
glyph = ""
delta_pt = max(10, int(base_pt * 0.95))
delta_h = Pt(delta_pt).emu * 1.5
_add_textbox(slide, inner_x, delta_y, inner_w, delta_h,
f"{glyph}{delta}",
font=font_mono, size_pt=delta_pt,
color_hex=delta_color, bold=False, align=PP_ALIGN.LEFT)
delta_y_end = delta_y + delta_h
else:
delta_y_end = delta_y
# Footnote at bottom.
if footnote:
foot_pt = max(7, int(base_pt * 0.7))
foot_h = Pt(foot_pt).emu * 1.4
foot_y = y + h - pad_y - foot_h
if foot_y < delta_y_end:
foot_y = delta_y_end + Pt(base_pt * 0.2).emu
_add_textbox(slide, inner_x, foot_y, inner_w, foot_h,
footnote,
font=font_body, size_pt=foot_pt,
color_hex=muted_hex, bold=False, align=PP_ALIGN.LEFT)
return tile
line chart
Single- or multi-series line chart drawn with native python-pptx connectors.
Usage
from charts.line.render import render
render(slide, data, tokens, bounds)Data shape
data = {
"title": "Revenue retention by cohort, first 12 months", # optional
"x_labels": ["M0", "M1", ..., "M12"],
"series": [
{"name": "Q1 '25", "values": [100, 98, ..., 106]},
{"name": "Q2 '25", "values": [100, 99, ..., 110]},
{"name": "Q3 '25", "values": [100, 102, ..., 113]}, # shorter ok
{"name": "Q4 '25", "values": [100, 104, 111, 118]}, # shorter ok
],
"x_label": "Months from cohort start", # optional
"y_label": "Revenue retention (%)", # optional
"emphasize_last_series": True, # primary style goes to last series
"end_labels": True, # direct end-of-line labels vs legend
}valuesmay be shorter thanx_labels(partial / in-flight series).- A value of
Noneis treated as missing and breaks the line.
Styling
- Background fills with
tokens["bg"]. - Emphasized series:
tokens["primary"], ~2.25pt. Other series:tokens["muted"], ~1.25pt, dashed variants for separation. - Single series always uses
tokens["primary"]. - Last point of the primary series is marked with a small
tokens["accent"]circle. - Gridlines and axes are hairlines in
tokens["muted"]. - Y tick labels use
tokens["font_mono"]; X tick labels usetokens["font_body"]. - Title uses
tokens["font_display"], bold, ~1.55× base size. - When
end_labels=False, a compact legend sits above the top-right of the plot area.
Proof
Ran python example.py — emits 5 .pptx files, one per mode. OK.
"""Run `render()` 5 times — once per mode — writing example-<mode>.pptx per run."""
import os
import sys
from pptx import Presentation
from pptx.util import Inches
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.dirname(HERE)) # charts/ for shared tokens module
from tokens import MODES # noqa: E402
from render import render # noqa: E402
DATA = {
"title": "Revenue retention by cohort, first 12 months",
"x_labels": ["M0", "M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8", "M9", "M10", "M11", "M12"],
"series": [
{"name": "Q1 '25", "values": [100, 98, 97, 98, 100, 101, 102, 103, 104, 104, 105, 105, 106]},
{"name": "Q2 '25", "values": [100, 99, 100, 102, 104, 105, 106, 106, 107, 108, 109, 110, 110]},
{"name": "Q3 '25", "values": [100, 102, 106, 109, 110, 112, 112, 113, 113]},
{"name": "Q4 '25", "values": [100, 104, 111, 118]},
],
"x_label": "Months from cohort start",
"y_label": "Revenue retention (%)",
"emphasize_last_series": True,
"end_labels": True,
}
def main():
out_dir = os.path.dirname(os.path.abspath(__file__))
for mode_name, tokens in MODES.items():
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
slide = prs.slides.add_slide(blank)
margin = Inches(0.4)
bounds = (
int(margin),
int(margin),
int(prs.slide_width - 2 * margin),
int(prs.slide_height - 2 * margin),
)
render(slide, DATA, tokens, bounds)
out_path = os.path.join(out_dir, f"example-{mode_name}.pptx")
prs.save(out_path)
print(f"wrote {out_path}")
if __name__ == "__main__":
main()
"""Line chart — native python-pptx shapes only.
Exposes a single public function: render(slide, data, tokens, bounds).
"""
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.util import Emu, Pt
def _rgb(hex_):
return RGBColor.from_string(hex_.lstrip("#"))
def _add_textbox(slide, x, y, w, h, text, font_name, size_pt, color_hex,
bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP):
tb = slide.shapes.add_textbox(Emu(int(x)), Emu(int(y)), Emu(int(w)), Emu(int(h)))
tf = tb.text_frame
tf.word_wrap = False
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
tf.vertical_anchor = anchor
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.name = font_name
run.font.size = Pt(size_pt)
run.font.bold = bold
run.font.color.rgb = _rgb(color_hex)
return tb
def _add_line(slide, x1, y1, x2, y2, color_hex, weight_pt, dash=None):
conn = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
Emu(int(x1)), Emu(int(y1)),
Emu(int(x2)), Emu(int(y2)))
line = conn.line
line.color.rgb = _rgb(color_hex)
line.width = Pt(weight_pt)
if dash is not None:
try:
line.dash_style = dash
except Exception:
pass
return conn
def _add_filled_circle(slide, cx, cy, r_emu, color_hex, line_color=None, line_weight_pt=0):
shp = slide.shapes.add_shape(MSO_SHAPE.OVAL,
Emu(int(cx - r_emu)), Emu(int(cy - r_emu)),
Emu(int(r_emu * 2)), Emu(int(r_emu * 2)))
shp.fill.solid()
shp.fill.fore_color.rgb = _rgb(color_hex)
if line_color is None:
shp.line.fill.background()
else:
shp.line.color.rgb = _rgb(line_color)
shp.line.width = Pt(line_weight_pt)
shp.shadow.inherit = False
return shp
def _add_rect_bg(slide, x, y, w, h, color_hex):
shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
Emu(int(x)), Emu(int(y)),
Emu(int(w)), Emu(int(h)))
shp.fill.solid()
shp.fill.fore_color.rgb = _rgb(color_hex)
shp.line.fill.background()
shp.shadow.inherit = False
return shp
def _nice_ticks(vmin, vmax, target=5):
if vmax <= vmin:
vmax = vmin + 1
span = vmax - vmin
import math
raw = span / max(target, 1)
mag = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1
for mult in (1, 2, 2.5, 5, 10):
step = mult * mag
if span / step <= target * 1.5:
break
lo = math.floor(vmin / step) * step
hi = math.ceil(vmax / step) * step
ticks = []
v = lo
while v <= hi + 1e-9:
ticks.append(round(v, 6))
v += step
return ticks, lo, hi
def _fmt_num(v):
if abs(v - round(v)) < 1e-6:
return f"{int(round(v))}"
return f"{v:.1f}"
def render(slide, data, tokens, bounds):
x0, y0, w0, h0 = bounds
bg = tokens["bg"]
primary = tokens["primary"]
accent = tokens["accent"]
text = tokens["text"]
muted = tokens["muted"]
font_display = tokens["font_display"]
font_body = tokens["font_body"]
font_mono = tokens["font_mono"]
base_pt = int(tokens["font_size_base_pt"])
# Background
_add_rect_bg(slide, x0, y0, w0, h0, bg)
title = data.get("title")
x_labels = list(data.get("x_labels", []))
series = list(data.get("series", []))
x_label = data.get("x_label")
y_label = data.get("y_label")
emphasize_last = bool(data.get("emphasize_last_series", False))
end_labels = bool(data.get("end_labels", False))
# Layout: top title area, optional y-label column on left, x-label row at bottom
pad = int(min(w0, h0) * 0.035)
title_h = Emu(0)
if title:
title_h = int(Pt(base_pt * 1.55).emu * 1.8)
sub_h = 0
if y_label:
sub_h = int(Pt(base_pt * 0.85).emu * 1.6)
# Title
cursor_y = y0 + pad
if title:
_add_textbox(slide, x0 + pad, cursor_y, w0 - 2 * pad, title_h,
title, font_display, int(base_pt * 1.55), text,
bold=True, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP)
cursor_y += title_h + int(pad * 0.3)
if y_label:
_add_textbox(slide, x0 + pad, cursor_y, w0 - 2 * pad, sub_h,
y_label, font_body, int(base_pt * 0.85), muted,
bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP)
cursor_y += sub_h
# Plot area
tick_pt = max(8, int(base_pt * 0.8))
x_axis_label_h = int(Pt(tick_pt).emu * 1.8)
x_tick_h = int(Pt(tick_pt).emu * 1.6)
# Reserve space on the right for end-of-line labels
right_pad = int(w0 * 0.14) if end_labels else int(w0 * 0.03)
# Reserve left for y tick labels
left_pad = int(w0 * 0.08)
bottom_pad = x_tick_h + (x_axis_label_h if x_label else 0) + int(pad * 0.5)
plot_x = x0 + pad + left_pad
plot_y = cursor_y + int(pad * 0.2)
plot_w = (x0 + w0 - pad - right_pad) - plot_x
plot_h = (y0 + h0 - pad - bottom_pad) - plot_y
if plot_w <= 0 or plot_h <= 0:
return
# Compute y range
all_vals = []
for s in series:
for v in s.get("values", []):
if v is not None:
all_vals.append(float(v))
if not all_vals:
return
vmin = min(all_vals)
vmax = max(all_vals)
# Pad y range slightly
span = vmax - vmin if vmax > vmin else max(abs(vmax), 1.0)
vmin_p = vmin - span * 0.08
vmax_p = vmax + span * 0.08
ticks, lo, hi = _nice_ticks(vmin_p, vmax_p, target=5)
if hi == lo:
hi = lo + 1
def y_to_emu(v):
frac = (v - lo) / (hi - lo)
return plot_y + plot_h - frac * plot_h
# X positions: evenly spaced across x_labels
n_x = max(len(x_labels), 1)
if n_x == 1:
def x_to_emu(i):
return plot_x + plot_w / 2
else:
step_x = plot_w / (n_x - 1)
def x_to_emu(i):
return plot_x + i * step_x
# Gridlines (horizontal, subtle)
for tv in ticks:
yy = y_to_emu(tv)
_add_line(slide, plot_x, yy, plot_x + plot_w, yy, muted, 0.5)
# Y tick labels (on left of plot)
tick_label_w = int(left_pad * 0.95)
tick_label_h = int(Pt(tick_pt).emu * 1.4)
for tv in ticks:
yy = y_to_emu(tv)
_add_textbox(slide,
plot_x - tick_label_w - int(pad * 0.15),
yy - tick_label_h / 2,
tick_label_w, tick_label_h,
_fmt_num(tv), font_mono, tick_pt, text,
bold=False, align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
# Left axis hairline
_add_line(slide, plot_x, plot_y, plot_x, plot_y + plot_h, muted, 0.75)
# Baseline (bottom axis)
_add_line(slide, plot_x, plot_y + plot_h, plot_x + plot_w, plot_y + plot_h, muted, 0.75)
# X tick labels
max_ticks = 13
skip = max(1, (n_x + max_ticks - 1) // max_ticks)
for i, lab in enumerate(x_labels):
if i % skip != 0 and i != n_x - 1:
continue
xx = x_to_emu(i)
label_w = int(plot_w / max(n_x, 1) * 1.6)
_add_textbox(slide,
xx - label_w / 2,
plot_y + plot_h + int(pad * 0.15),
label_w, x_tick_h,
str(lab), font_body, tick_pt, text,
bold=False, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.TOP)
# X-axis label
if x_label:
_add_textbox(slide,
plot_x, plot_y + plot_h + x_tick_h + int(pad * 0.2),
plot_w, x_axis_label_h,
x_label, font_body, int(base_pt * 0.85), muted,
bold=False, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.TOP)
# Draw series
n_series = len(series)
dash_styles = [None, None]
try:
from pptx.enum.dml import MSO_LINE
dash_styles = [MSO_LINE.DASH, MSO_LINE.ROUND_DOT, MSO_LINE.LONG_DASH, MSO_LINE.DASH_DOT]
except Exception:
dash_styles = [None, None, None, None]
for idx, s in enumerate(series):
values = s.get("values", [])
name = s.get("name", f"Series {idx+1}")
is_primary_idx = (idx == n_series - 1) if emphasize_last else (idx == 0)
if n_series == 1:
color = primary
weight = 2.25
dash = None
elif emphasize_last:
if is_primary_idx:
color = primary
weight = 2.5
dash = None
else:
color = muted
weight = 1.25
# dash pattern for variety on non-emphasized series
ds_idx = (n_series - 2 - idx) % max(len(dash_styles) - 1, 1)
dash = dash_styles[ds_idx + 1] if len(dash_styles) > 1 else None
else:
if idx == 0:
color = primary
weight = 2.25
dash = None
else:
color = muted
weight = 1.25
ds_idx = (idx - 1) % max(len(dash_styles) - 1, 1)
dash = dash_styles[ds_idx + 1] if len(dash_styles) > 1 else None
# Build list of valid points
pts = []
for i, v in enumerate(values):
if v is None or i >= n_x:
continue
pts.append((x_to_emu(i), y_to_emu(float(v)), i, float(v)))
# Draw segments
for a, b in zip(pts, pts[1:]):
_add_line(slide, a[0], a[1], b[0], b[1], color, weight, dash=dash)
# Highlight last data point of primary/emphasized series with accent
if is_primary_idx and pts:
last = pts[-1]
r = max(Emu(Pt(3).emu).emu, int(Pt(3.5).emu))
# Outer dot in accent
_add_filled_circle(slide, last[0], last[1], int(Pt(4).emu), accent)
# Inner white-ish in bg for contrast
_add_filled_circle(slide, last[0], last[1], int(Pt(1.6).emu), bg)
# End-of-line labels
if end_labels and pts:
last = pts[-1]
lbl_w = int(right_pad * 0.95)
lbl_h = int(Pt(tick_pt).emu * 1.6)
_add_textbox(slide,
last[0] + int(Pt(4).emu),
last[1] - lbl_h / 2,
lbl_w, lbl_h,
str(name), font_body, tick_pt,
color if color != muted else muted,
bold=is_primary_idx, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.MIDDLE)
# If not end_labels, render a compact legend at top-right of plot area
if not end_labels and n_series > 1:
legend_y = plot_y - int(Pt(tick_pt).emu * 1.5)
if legend_y < y0 + pad:
legend_y = plot_y + int(Pt(tick_pt).emu * 0.2)
cx = plot_x + plot_w
swatch_w = int(Pt(tick_pt).emu * 0.9)
gap = int(Pt(tick_pt).emu * 0.4)
# Build legend right-to-left
for idx in range(n_series - 1, -1, -1):
s = series[idx]
name = s.get("name", f"Series {idx+1}")
is_primary_idx = (idx == n_series - 1) if emphasize_last else (idx == 0)
color = primary if (is_primary_idx or n_series == 1) else muted
# Estimate label width
est_w = int(Pt(tick_pt).emu * 0.55 * max(len(name), 3))
cx -= est_w
_add_textbox(slide,
cx, legend_y,
est_w, int(Pt(tick_pt).emu * 1.4),
name, font_body, tick_pt, text,
bold=is_primary_idx, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.MIDDLE)
cx -= gap
# Swatch line
sy = legend_y + int(Pt(tick_pt).emu * 0.7)
_add_line(slide, cx - swatch_w, sy, cx, sy, color, 2.0)
cx -= swatch_w + gap * 2
Mode Tokens — synthesized from /Users/bassime/Downloads/joy.md
Every chart in this experiment accepts a tokens dict with exactly these keys:
primary(str) — hex color "#RRGGBB" (main chart color / primary data series)accent(str) — hex color "#RRGGBB" (secondary data color / highlights)text(str) — hex color "#RRGGBB" (foreground / labels)muted(str) — hex color "#RRGGBB" (axes, gridlines, secondary labels)bg(str) — hex color "#RRGGBB" (slide background / chart fill)font_display(str) — font family name for large titles/numbersfont_body(str) — font family name for labelsfont_mono(str) — font family name for numeric columns (may equal font_body in modes without a mono)font_size_base_pt(int) — body text size in pointsradius_px(int) — corner radius in pixels (optional, 0 for sharp corners)
Use ONLY these keys. Use pptx.util.Emu and pptx.util.Pt for conversions. No hardcoded colors or fonts anywhere.
The 5 modes
sv-keynote (Silicon Valley Keynote)
SV_KEYNOTE = {
"primary": "#21D4FD", # neon cyan accent
"accent": "#17B26A", # success green for secondary
"text": "#F5F7FA", # near-white foreground
"muted": "#9AA4B2", # cool grey
"bg": "#05070A", # black-glass
"font_display": "Manrope",
"font_body": "Manrope",
"font_mono": "JetBrains Mono",
"font_size_base_pt": 18,
"radius_px": 6,
}Vibe: cinematic, black-glass stagecraft, huge type, one electric accent.
editorial-magazine (Editorial Magazine)
EDITORIAL_MAGAZINE = {
"primary": "#8C2F39", # literary red-wine accent
"accent": "#9C5B00", # warm amber
"text": "#181514", # ink black
"muted": "#6F675F", # warm grey
"bg": "#F6F1E8", # warm paper
"font_display": "Fraunces",
"font_body": "Newsreader",
"font_mono": "IBM Plex Mono",
"font_size_base_pt": 16,
"radius_px": 0,
}Vibe: literate, warm, sharply edited. Hairlines > boxes. Serif display.
playful-marketing (Playful Marketing)
PLAYFUL_MARKETING = {
"primary": "#FF7A00", # saturated orange
"accent": "#0AB39C", # teal counterweight
"text": "#1B1B1F", # near-black
"muted": "#6E6A73", # warm grey
"bg": "#FFF4EB", # peach cream
"font_display": "Bricolage Grotesque",
"font_body": "Plus Jakarta Sans",
"font_mono": "Recursive Mono",
"font_size_base_pt": 18,
"radius_px": 12,
}Vibe: confident, warm, kinetic, internet-native, slightly mischievous.
consulting-boardroom (Consulting Boardroom)
CONSULTING_BOARDROOM = {
"primary": "#0F4C81", # navy
"accent": "#05603A", # dark green for favorable deltas
"text": "#101828", # near-black
"muted": "#475467", # steel grey
"bg": "#FFFFFF", # pure white
"font_display": "Public Sans",
"font_body": "Public Sans",
"font_mono": "Public Sans",
"font_size_base_pt": 14,
"radius_px": 0,
}Vibe: sober, structured, expensive paper, no nonsense. Takeaway titles; hairline rules; tabular figures.
craft-minimal (Craft Minimal)
CRAFT_MINIMAL = {
"primary": "#7C8571", # muted sage
"accent": "#9A6B39", # oxidized copper
"text": "#22201C", # warm ink
"muted": "#7B776F", # stone grey
"bg": "#FCFBF8", # off-white paper
"font_display": "Instrument Serif",
"font_body": "Instrument Sans",
"font_mono": "Instrument Sans",
"font_size_base_pt": 16,
"radius_px": 2,
}Vibe: quiet, tactile, restrained, museum-bookstore expensive. Enormous margins. Almost no color.
Key distinctions charts MUST honor
bgcolor: sv-keynote is dark; the other four are light. Chart fills, axis lines, and text must adapt. NEVER hardcodewhiteorblack.primaryvaries wildly: cyan neon, wine red, orange, navy, sage. Charts should feel materially different.font_displayvsfont_body: usefont_displayon big numbers (KPI values) and optional chart headings; usefont_bodyon labels/axis ticks. Usefont_monowhere numeric alignment matters (axis values, KPI deltas).radius_px: convert to EMUs and use on bar corners / cell corners where the chart supports rounding. 0 = sharp.
EMU primer
python-pptx works in English Metric Units. 914400 EMU per inch. 12700 EMU per point. Use pptx.util.Emu(n), pptx.util.Inches(n), pptx.util.Pt(n) helpers.
"""Canonical per-mode token dicts.
Single source of truth for the 5 chart modes. Every `example.py` imports
`MODES` from here instead of redefining the block. Keep in sync with
`MODE_TOKENS.md`.
"""
MODES = {
"sv-keynote": {
"primary": "#21D4FD",
"accent": "#17B26A",
"text": "#F5F7FA",
"muted": "#9AA4B2",
"bg": "#05070A",
"font_display": "Manrope",
"font_body": "Manrope",
"font_mono": "JetBrains Mono",
"font_size_base_pt": 18,
"radius_px": 6,
},
"editorial-magazine": {
"primary": "#8C2F39",
"accent": "#9C5B00",
"text": "#181514",
"muted": "#6F675F",
"bg": "#F6F1E8",
"font_display": "Fraunces",
"font_body": "Newsreader",
"font_mono": "IBM Plex Mono",
"font_size_base_pt": 16,
"radius_px": 0,
},
"playful-marketing": {
"primary": "#FF7A00",
"accent": "#0AB39C",
"text": "#1B1B1F",
"muted": "#6E6A73",
"bg": "#FFF4EB",
"font_display": "Bricolage Grotesque",
"font_body": "Plus Jakarta Sans",
"font_mono": "Recursive Mono",
"font_size_base_pt": 18,
"radius_px": 12,
},
"consulting-boardroom": {
"primary": "#0F4C81",
"accent": "#05603A",
"text": "#101828",
"muted": "#475467",
"bg": "#FFFFFF",
"font_display": "Public Sans",
"font_body": "Public Sans",
"font_mono": "Public Sans",
"font_size_base_pt": 14,
"radius_px": 0,
},
"craft-minimal": {
"primary": "#7C8571",
"accent": "#9A6B39",
"text": "#22201C",
"muted": "#7B776F",
"bg": "#FCFBF8",
"font_display": "Instrument Serif",
"font_body": "Instrument Sans",
"font_mono": "Instrument Sans",
"font_size_base_pt": 16,
"radius_px": 2,
},
}
Evidence
How the three additions in this skill were chosen, and how the stacked combination was measured.
Method
Single-change ablations against a strong baseline (Anthropic's open-source pptx skill), evaluated by a blind LLM judge over five distinct deck-generation scenarios.
- Baseline (`i0`): unmodified Anthropic
pptxskill. - Variants (`i1`–`i15`): 15 single-change variants grouped into three axes.
- Axis A (5 variants): prose-only additions to
SKILL.md(style guide, design principles, mood mappings). - Axis B (5 variants): post-generation alignment / QA scripts (bounds, overflow, contrast, round-trip, fonts).
- Axis C (5 variants): diversity / outline / must-include interventions.
- Scenarios (5): 10-slide Series B keynote pitch (cinematic / OLED); 5-slide exec update; 15-slide technical deep-dive; 12-slide launch deck (playful); 20-slide board read-ahead (Economist-style).
- Generator:
claude -p --model sonnetper scenario, parallelized. Executes the full skill workflow end-to-end (parse brief, author in Python, render, QA, save). - Implementer:
claude -p --model opus, one run per variant, applies the single variant change to the baseline skill. - Judge:
claude -p --model opus, blind to variant identity. Sees only the scenario brief, 15 PNGs (slides 1–3 from each of the 5 scenarios), and a fixed 0–5 rubric. - Rubric per scenario (max 15): Style (0–5), Alignment (0–5), Diversity (0–5).
- Aggregate score per variant: mean of per-scenario totals across the 5 scenarios.
Keep criterion (variant kept iff both): 1. mean_total ≥ baseline + 0.3 (i.e. ≥ 11.70). 2. Variant beats baseline's per-scenario total on ≥ 3 of 5 scenarios individually.
Leaderboard
| Rank | Iter | Tag | Axis | Mean | Style | Align | Divers | s1 | s2 | s3 | s4 | s5 | Wins vs base | Verdict |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | i6 | b1-bounds | B | 13.40 | 4.80 | 5.00 | 3.60 | 14 | 13 | 12 | 14 | 14 | 4/5 | KEEP |
| 2 | i5 | a5-impeccable | A | 13.20 | 4.60 | 5.00 | 3.60 | 14 | 14 | 12 | 13 | 13 | 3/5 | KEEP |
| 3 | i14 | c4-adherence-check | C | 12.80 | 4.20 | 5.00 | 3.60 | 12 | 13 | 12 | 14 | 13 | 3/5 | KEEP |
| 4 | i4 | a4-principles | A | 12.60 | 4.00 | 5.00 | 3.60 | 13 | 13 | 13 | 12 | 12 | 2/5 | discard |
| 5 | i7 | b2-overflow | B | 12.60 | 4.20 | 4.80 | 3.60 | 13 | 13 | 12 | 13 | 12 | 2/5 | discard |
| 6 | i12 | c2-anti-boilerplate | C | 12.40 | 4.00 | 4.80 | 3.60 | 12 | 13 | 13 | 12 | 12 | 2/5 | discard |
| 7 | i1 | a1-tokens | A | 12.20 | 4.20 | 4.60 | 3.40 | 12 | 11 | 12 | 12 | 14 | 2/5 | discard |
| 8 | i3 | a3-archetypes | A | 12.20 | 4.00 | 4.80 | 3.40 | 12 | 10 | 13 | 14 | 12 | 2/5 | discard |
| 9 | i9 | b4-roundtrip | B | 12.20 | 3.80 | 4.80 | 3.60 | 12 | 12 | 12 | 14 | 11 | 3/5 | KEEP (rank 4) |
| 10 | i13 | c3-archetype-diversity | C | 12.00 | 3.80 | 4.80 | 3.40 | 13 | 13 | 12 | 9 | 13 | 2/5 | discard |
| 11 | i15 | c5-must-include-parser | C | 11.80 | 3.60 | 4.80 | 3.40 | 13 | 13 | 11 | 12 | 10 | 2/5 | discard |
| 12 | i2 | a2-shame | A | 11.60 | 3.80 | 4.40 | 3.40 | 13 | 13 | 13 | 8 | 11 | 2/5 | discard |
| 13 | i0 | baseline | - | 11.40 | 4.00 | 3.80 | 3.60 | 13 | 10 | 13 | 13 | 8 | - | baseline |
| 14 | i10 | b5-fonts | B | 10.80 | 3.80 | 3.80 | 3.20 | 12 | 8 | 8 | 13 | 13 | 1/5 | discard |
| 15 | i11 | c1-outlines | C | 10.20 | 3.40 | 4.00 | 2.80 | 13 | 12 | 10 | 11 | 5 | 1/5 | discard |
| 16 | i8 | b3-contrast | B | 9.60 | 3.40 | 3.00 | 3.20 | 5 | 12 | 12 | 11 | 8 | 1/5 | discard |
Top-3 kept variants
i6 — b1-bounds (Axis B, mean 13.40, wins 4/5)
Added scripts/check_bounds.py, a small Python script that parses the emitted .pptx and asserts every shape's (x, y, w, h) is inside the 12,192,000 × 6,858,000 EMU canvas. Wired a call into the SKILL.md build flow so the generator runs it post-generation; on any out-of-bounds violation the generator is instructed to move/resize the shape and re-emit.
Why it's the top result: the bounds check is the cheapest, most deterministic signal for alignment (which was baseline's weakest dimension — 3.80). With it the generator reliably stops emitting shapes that clip off-canvas, lifting align from 3.80 → 5.00 while leaving the creative side of the skill untouched.
i5 — a5-impeccable (Axis A, mean 13.20, wins 3/5)
Added an explicit mood → mode mapping and authoring examples to SKILL.md. Prose only; no tokens, no scripts. The generator is asked to classify the brief's mood and commit to exactly one mode, with a discipline rule that bans mixing elements from another mode.
Why it works: gives the generator a vocabulary for mapping brief → visual register (cinematic vs. board-deck vs. literary) instead of leaving that decision unguided. Pays off most on scenarios 1 and 2 (cinematic + board-deck briefs) where it scored a perfect 14.
i14 — c4-adherence-check (Axis C, mean 12.80, wins 3/5)
Added a post-generation self-check to SKILL.md: re-read the scenario's must-includes, list which are covered vs. missing, then patch the missing ones before finalising. The audit uses markitdown to extract text from the .pptx and mark every must-include as COVERED / MENTIONED / MISSING.
Why it works: the judge rewards visible adherence to scenario must-includes under the Diversity rubric, and this change directly targets that. Notably, it adds no code path — the whole gain is a reliable self-audit loop, which the baseline was skipping.
Combination round
Stacked the three kept winners into one merged variant icombo-a5b1c4. Same blind-judge rubric, same 5 scenarios, same implementer=Opus / generator=Sonnet / judge=Opus pipeline. Post-gen order in the merged SKILL.md: visual QA → bounds check → must-include self-audit.
Combo result vs individual winners
| Variant | Axis | Mean | Style | Align | Divers | s1 | s2 | s3 | s4 | s5 | Δ vs baseline |
|---|---|---|---|---|---|---|---|---|---|---|---|
| baseline (i0) | - | 11.40 | 4.00 | 3.80 | 3.60 | 13 | 10 | 13 | 13 | 8 | — |
| i5 a5-impeccable | A | 13.20 | 4.60 | 5.00 | 3.60 | 14 | 14 | 12 | 13 | 13 | +1.80 |
| i6 b1-bounds | B | 13.40 | 4.80 | 5.00 | 3.60 | 14 | 13 | 12 | 14 | 14 | +2.00 |
| i14 c4-adherence-check | C | 12.80 | 4.20 | 5.00 | 3.60 | 12 | 13 | 12 | 14 | 13 | +1.40 |
| icombo a5+b1+c4 | combo | 13.60 | 4.60 | 5.00 | 4.00 | 14 | 13 | 13 | 14 | 14 | +2.20 |
Pre-registered thresholds: ≥13.70 = compound, 13.20–13.70 = neutral, ≤13.20 = interfere. The combo landed at 13.60 — inside the neutral band, 0.10 below the compound threshold and 0.20 above the best individual. No interference (the stack beats baseline by +2.20 and edges every individual winner), but no real compounding either: alignment is already pinned at 5.00 from b1 alone; style sits at the 4.60 a5 hit solo; the only real movement is diversity (3.60 → 4.00), which is where c4 was designed to help.
Net: the three interventions pull on largely overlapping judge levers rather than adding independent axes. The combined skill is a modest all-rounder improvement over any single change, and it is the configuration shipped in this repository.
Synthesis: why these three
Two paragraphs on why these three survived and others did not.
Every kept variant targets a different failure mode of the baseline, and every dropped variant either overlapped with one that was kept or actively regressed some dimension. Baseline's weakest rubric axis was alignment (3.80). Axis B's b1-bounds is a deterministic alignment backstop — the cheapest possible lift, and the largest — so it dominates B. Axis A's best entries all move style from 4.00 → 4.20–4.60, but only a5-impeccable pairs the prose with concrete examples that give the generator a mode vocabulary it can actually commit to; pure-criticism variants (a2-shame) teach the model what not to do without giving it a positive target, and tokenized variants (a1-tokens) move the needle less because the baseline already implicitly understands tokens. Axis C is the most interesting: the outline-generation variant (c1-outlines) cost more than it paid back — more tokens on planning, less on polish — and collapsed scenario 5 to a 5. The c4-adherence-check won Axis C because it operates after the creative work is done, so it cannot damage style; it only patches the content-fidelity failure mode that the Diversity rubric explicitly rewards.
The combination is a modest compound, not a multiplicative one, because the three interventions converge on overlapping judge levers rather than independent axes. Once b1-bounds pins alignment at 5.00, there is no alignment headroom left for the other two to claim. Once a5-impeccable chooses a coherent visual register, style saturates around 4.60 regardless of what else runs. The only axis with meaningful remaining headroom is Diversity (which, in this rubric, measures must-include coverage and pattern variety), and that is exactly where c4 moves the score (3.60 → 4.00). The combined skill lands at 13.60 out of 15 — a +2.20 over baseline, which is a meaningful lift on a rubric where the top-1 single change was +2.00. It is the configuration shipped in this repository. Further work would most plausibly come from genuinely new axes (e.g., chart-quality scoring, or a render-then-edit loop that compares visual output to the brief rather than just text fidelity), not from stacking more variants on the axes already covered.
#!/usr/bin/env python3
"""Check that every shape in a .pptx lies within the 16:9 widescreen canvas."""
import argparse
import json
import subprocess
import sys
try:
from pptx import Presentation
except ImportError:
subprocess.run(
[sys.executable, "-m", "pip", "install", "python-pptx", "--quiet"],
check=True,
)
from pptx import Presentation
CANVAS_W = 12192000 # 13.333" in EMU
CANVAS_H = 6858000 # 7.5" in EMU
def shape_label(shape):
name = getattr(shape, "name", None)
if name:
return name
if getattr(shape, "is_placeholder", False):
try:
return f"placeholder:{shape.placeholder_format.type}"
except Exception:
return "placeholder"
return "shape"
def check(path):
prs = Presentation(path)
violations = []
for sidx, slide in enumerate(prs.slides, start=1):
for shape in slide.shapes:
L = shape.left
T = shape.top
W = shape.width
H = shape.height
if L is None or T is None or W is None or H is None:
continue
edges = []
if L < 0:
edges.append("left<0")
if T < 0:
edges.append("top<0")
if L + W > CANVAS_W:
edges.append("right>canvas")
if T + H > CANVAS_H:
edges.append("bottom>canvas")
if edges:
violations.append({
"slide": sidx,
"shape": shape_label(shape),
"bounds": [L, T, W, H],
"violation": ",".join(edges),
})
return violations
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pptx")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
violations = check(args.pptx)
if args.json:
print(json.dumps({"violations": violations}, indent=2))
else:
for v in violations:
L, T, W, H = v["bounds"]
print(
f"slide={v['slide']} shape={v['shape']} "
f"bounds=({L},{T},{W},{H}) violation={v['violation']}"
)
sys.exit(1 if violations else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Render a .pptx to per-slide PNGs using headless LibreOffice + pdftoppm.
Usage:
python scripts/render_preview.py path/to/deck.pptx [--out DIR] [--dpi 100]
Writes ``DIR/slide-01.png``, ``DIR/slide-02.png``, ... next to an intermediate
PDF. Default output dir is ``<pptx-basename>-preview/`` next to the pptx.
Exits 0 on success, 1 if ``soffice`` or ``pdftoppm`` are missing or convert.
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
def require(binary: str) -> str:
path = shutil.which(binary)
if not path:
print(
f"error: {binary!r} not found on PATH. "
f"Install LibreOffice (soffice) and poppler (pdftoppm).",
file=sys.stderr,
)
sys.exit(1)
return path
def render(pptx: Path, out_dir: Path, dpi: int) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
soffice = require("soffice")
pdftoppm = require("pdftoppm")
# 1. pptx -> pdf via LibreOffice headless.
subprocess.run(
[
soffice,
"--headless",
"--convert-to", "pdf",
"--outdir", str(out_dir),
str(pptx),
],
check=True,
)
pdf = out_dir / (pptx.stem + ".pdf")
if not pdf.exists():
print(f"error: expected {pdf} after soffice conversion", file=sys.stderr)
sys.exit(1)
# 2. pdf -> per-slide PNGs.
subprocess.run(
[
pdftoppm,
"-r", str(dpi),
"-png",
str(pdf),
str(out_dir / "slide"),
],
check=True,
)
pngs = sorted(out_dir.glob("slide-*.png"))
print(f"rendered {len(pngs)} slides to {out_dir}")
for p in pngs:
print(f" {p}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("pptx")
ap.add_argument("--out", default=None, help="output directory (default: <pptx>-preview)")
ap.add_argument("--dpi", type=int, default=100)
args = ap.parse_args()
pptx = Path(args.pptx).resolve()
if not pptx.exists():
print(f"error: {pptx} does not exist", file=sys.stderr)
sys.exit(1)
out_dir = Path(args.out).resolve() if args.out else pptx.with_name(pptx.stem + "-preview")
render(pptx, out_dir, args.dpi)
if __name__ == "__main__":
main()
Related skills
FAQ
What format does Beautiful Slides output?
It outputs editable PowerPoint .pptx files authored with python-pptx, sized for 16:9 widescreen.
What does it require to run?
Python 3, python-pptx, and a headless LibreOffice (soffice), plus pdftoppm for rendering slides to PNG.