Skip to main content
Glama
README.md
# dial-pptx-mcp

A remote, multi-tenant **PowerPoint-generation MCP server for [EPAM AI DIAL](https://dialx.ai)**.

Generates `.pptx` presentations from corporate templates while preserving the template's theme, layouts, masters, and branding. Designed to be registered as an `MCPToolSet` in [DIAL Quick Apps](https://github.com/epam/ai-dial-quickapps-backend), with file input/output flowing through DIAL Core file storage.

This project extends [GongRzhe/Office-PowerPoint-MCP-Server](https://github.com/GongRzhe/Office-PowerPoint-MCP-Server) (MIT) — see [Credits](#credits).

## What this adds over upstream

| Area | Upstream | This project |
|---|---|---|
| Transport | stdio (single local client) | streamable-http / SSE, container-ready (`PPT_MCP_*` env vars) |
| State | process globals, guessable sequential IDs | per-deck UUID handles (unguessable), thread-safe store with TTL + LRU bounds, per-deck locking |
| File I/O | local disk paths | DIAL Files API: template in via Quick Apps `file:data::` references, deck out via `export_presentation` returning a DIAL file URL |
| Images | local path or base64 | `add_image_from_dial_url` fetches orchestrator-generated images from DIAL storage server-side, with aspect-ratio-aware placement |
| Asset library | none | `list_assets` + `add_asset_to_slide`: brand icons and other pictures mounted into the pod (`PPT_ASSET_PATH`), placed by name with the same aspect-ratio-aware fitting — no DIAL file sharing needed |
| Icons | none | `render_svg_icon` + `add_icon_to_slide`: the agent draws the icon as SVG (`get_icon_guidance` holds the style guide), the server rasterizes it to a transparent PNG, has the vision model check the render for artifacts, and holds it under a handle for placement |
| Deck structure | append-only (`add_slide` at the end) | `duplicate_slide`, `delete_slide`, `move_slide`, `copy_slide_between_presentations`, and `manage_shape` (delete, move, restack, move an element to another slide) — python-pptx has no API for any of these |
| Speaker notes | none | `manage_speaker_notes`, carried across duplication, reported by the text-extraction tools |
| Validation | none | `validate_presentation`: package, relationship, geometry, chart, table and placeholder-text checks, folded into export |
| Design | — | `get_design_guidance` (deck design as a document the agent reads), `get_template_instructions` (the template author's own rules, shipped as a `.md` beside the template instead of in the system prompt), `render_slide_previews` (see the template before building on it) and `render_deck_summary_card` (show the finished deck as one image) |
| Charts | one type, one axis per chart | `add_combo_chart` (mixed series types, secondary value axis) and `format_chart_series` (colour, labels, trendlines) |
| Output | `.pptx` | `.pptx`, `.pdf`, or both; PowerPoint 97-2003 `.ppt` accepted as input |
| Deployment | — | Dockerfile (non-root, HTTP defaults) + generic Kubernetes example |

The ~30 upstream content/formatting tools (slides, text, charts, tables, connectors, hyperlinks, masters, transitions) are unchanged — see [docs/UPSTREAM_README.md](docs/UPSTREAM_README.md) for the full tool reference.

## Configuration

All environment-specific settings come from environment variables. Nothing is hardcoded.

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `PPT_MCP_TRANSPORT` | no | `stdio` | `http` (streamable-http, recommended for DIAL), `sse`, or `stdio` |
| `PPT_MCP_HOST` | no | `127.0.0.1` | Bind address; set `0.0.0.0` in containers (the Dockerfile does) |
| `PPT_MCP_PORT` | no | `8000` | Listen port for http/sse |
| `PPT_MCP_ALLOWED_HOSTS` | no | `dial-pptx-mcp.dial.svc.cluster.local` | Comma-separated Host-header allowlist for http/sse. Only listed hosts (plus localhost) are accepted; others get `421`. The default matches the standard in-cluster service name — override with your own hostname(s), or set `*` to disable Host checking entirely (e.g. behind ingress under other names). Loopback binds keep the SDK's localhost-only protection |
| `DIAL_CORE_URL` | for DIAL export | — | Base URL of DIAL Core, e.g. `https://dial.example.com`. Unset → DIAL upload/download tools return a clear error; local-path tools still work |
| `DIAL_AUTH_MODE` | no | `auto` | `auto`: credentials from the incoming MCP request first (the end user's bearer, attached by Quick Apps for servers deployed under the DIAL host — exports land in that user's own bucket), falling back to `DIAL_API_KEY`. `caller`: incoming credentials only — fail loudly instead of falling back. `server`: always `DIAL_API_KEY` (single shared bucket) |
| `DIAL_API_KEY` | no | — | Server's own DIAL API key — the fallback identity in `auto` mode, the only identity in `server` mode |
| `DIAL_UPLOAD_FOLDER` | no | `pptx-mcp` | Folder inside the bucket for exported decks |
| `DIAL_PUBLIC_URL` | no | — | Extra host(s) DIAL file links may carry besides `DIAL_CORE_URL` (comma-separated URLs or hostnames). Needed when the server reaches Core in-cluster but the orchestrator holds public `https://chat.example.com/api/files/...` links. The bytes are always fetched from `DIAL_CORE_URL` — only the path is taken from the link |
| `DIAL_IMAGE_MAX_MB` | no | `20` | Largest image `add_image_from_dial_url` will download and embed. An unparsable value falls back to the default |
| `PPT_MCP_MAX_ICONS` | no | `100` | Rendered icons held in memory for `add_icon_to_slide`, LRU-evicted (they expire on `PPT_MCP_STATE_TTL_SECONDS` like presentations) |
| `TEMPLATE_INSTRUCTIONS_MAX_KB` | no | `128` | Largest template instructions sidecar the server will accept beside a template. The document is a page or two of rules for using that template, not a style manual. An unparsable value falls back to the default |
| `SVG_ICON_MAX_KB` | no | `64` | Largest SVG source `render_svg_icon` will rasterize. An icon is a handful of paths; the limit is what stops traced artwork arriving as an "icon". An unparsable value falls back to the default |
| `PPT_MCP_STATE_TTL_SECONDS` | no | `3600` | Idle time before an in-memory presentation expires |
| `PPT_MCP_STATE_MAX_PRESENTATIONS` | no | `50` | Max concurrently held presentations (LRU eviction) |
| `PPT_MCP_MAX_CONCURRENT_TOOL_CALLS` | no | `max(4, 2x CPU count)` | Max tool calls running at once, process-wide (see [Multi-tenancy and scaling notes](#multi-tenancy-and-scaling-notes)). Bounds a burst of parallel tool calls (many tenants, or several `render_svg_icon` calls in one agent turn) so the pod's CPU can't be flooded |
| `PPT_MCP_MAX_CONCURRENT_CONVERSIONS` | no | `2` | Max LibreOffice conversions running at once, process-wide. These are the memory-heavy calls (~0.5 GB per `soffice` process), so they are capped separately from `PPT_MCP_MAX_CONCURRENT_TOOL_CALLS`; calls above the cap queue rather than fail. Raise it only with pod memory to match |
| `PPT_TEMPLATE_PATH` | no | — | Extra local directories searched by the local-path template tools (`:`-separated) |
| `PPT_ASSET_PATH` | no | — | Folder holding the server-side asset library (PNG/JPEG/GIF) served by `list_assets` and `add_asset_to_slide`. Unset → both tools return an error telling the agent to draw the icon instead. See [Asset library](#asset-library-server-side-images) |
| `VISION_LLM_MODEL` | for visual QA | — | Vision model: the model name (direct endpoint) or the DIAL deployment name (DIAL provider); must accept image input |
| `VISION_LLM_ENDPOINT` | direct provider | — | OpenAI Responses-API endpoint, e.g. `https://<resource>.openai.azure.com/openai/responses?api-version=2025-04-01-preview`. When unset, the model is called through DIAL Core instead: `{DIAL_CORE_URL}/openai/deployments/{model}/chat/completions` with DIAL credentials (caller headers first, `DIAL_API_KEY` fallback) |
| `VISION_LLM_API_KEY` | direct provider | — | Key for the direct endpoint (sent as `api-key` and `Authorization: Bearer`) |
| `VISION_LLM_PROVIDER` | no | auto | Force the backend: `direct` or `dial` (default: `direct` when `VISION_LLM_ENDPOINT` is set, else `dial`) |
| `VISION_LLM_API_VERSION` | no | `2025-04-01-preview` | `?api-version=` added to the vision call when the endpoint URL doesn't already carry one. Azure OpenAI (and DIAL Core's Azure upstream) reject requests without it — `api-version is a required query parameter`. The default covers both the Responses API and chat completions with image input; an `api-version` already present in `VISION_LLM_ENDPOINT` always wins |
| `VISION_LLM_MAX_SLIDES` | no | `60` | Most slides one whole-deck review covers. Slides beyond it come back in `slides_not_reviewed` and the deck is not marked `passed` (an explicit `slides` list is never capped) |
| `VISION_LLM_BATCH_SLIDES` | no | `6` | Slides per vision request. A deck is reviewed in batches of this size, run in parallel; one request carrying 15+ slides is where the reviewer started missing blank slides |
| `VISION_LLM_MAX_PARALLEL` | no | `4` | Vision requests in flight at once for one review (the coherence call counts as one) |
| `VISUAL_QA_COHERENCE` | no | `true` | Whole-deck calls also run the deck-level story review (agenda vs. sections, content on the wrong slide, blank slides, contradictions) — one text-only call. `false` turns it off |
| `VISUAL_QA_ENFORCE` | no | `true` | `false` unregisters the visual QA tools entirely. It governs slide inspection only — `render_svg_icon` still reviews an icon whenever a vision model is configured |
| `VISUAL_QA_MAX_ITERATIONS` | no | `3` | Inspect/repair rounds per `visual_repair_slides` call (overridable per call) |
| `VISUAL_QA_EXPORT_GATE` | no | `false` | `true` also runs a whole-deck inspect-repair loop inside export/save and refuses unverified decks |
| `VISUAL_QA_ON_UNRESOLVED` | no | `report` | Export gate only: `report` fails the export with the issue list, `export_as_is` ships the deck |
| `VISUAL_QA_ON_ERROR` | no | `block` | Export gate only: `allow` exports when inspection itself cannot run |
| `SOFFICE_PATH` | no | `soffice` on PATH | LibreOffice binary used to render slides (the Docker image includes LibreOffice) |
| `LOG_LEVEL` | no | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL`. One log line per event on stderr — see [Logging](#logging). An unrecognized value falls back to `INFO` rather than failing startup |

Copy [`.env.example`](.env.example) to `.env` for local runs — the server loads it at startup, and `.env` is gitignored.

## Running

Local (stdio, for MCP-client desktop use):

```bash
pip install -r requirements.txt
python ppt_mcp_server.py
```

Remote (streamable-http):

```bash
docker build -t dial-pptx-mcp .
docker run -p 8000:8000 -e DIAL_CORE_URL=https://dial.example.com -e DIAL_API_KEY=... dial-pptx-mcp
# MCP endpoint: http://<host>:8000/mcp
```

Kubernetes: see [deploy/kubernetes.yaml](deploy/kubernetes.yaml) (generic example — replace placeholders).

## DIAL Quick Apps integration

Register the deployed server as an MCP tool set in your Quick App manifest:

```json
{
  "name": "powerpoint",
  "description": "Generate PowerPoint presentations from corporate templates",
  "type": "mcp",
  "mcp_server_info": {
    "url": "https://YOUR-DEPLOYED-HOST/mcp",
    "protocol": "streamable_http",
    "authorization": null
  },
  "attachment": {
    "supported_types": ["*/*"],
    "propagate_types_to_choice": [
      "application/vnd.openxmlformats-officedocument.presentationml.presentation"
    ]
  }
}
```

- `propagate_types_to_choice` makes the exported `.pptx` attachment visible to the end user in DIAL Chat (tool-call results are hidden by default).
- **Deploy the server under the DIAL host** (behind DIAL Core routing) for per-user storage. Quick Apps attaches the end user's `Authorization: Bearer` only to MCP servers whose URL starts with the DIAL host — with it, the default `auto` mode uploads every export to that user's own bucket. For a server at an external URL, Quick Apps sends no user credentials (it deliberately refuses to forward `api-key`/`authorization` as custom headers), so `auto` falls back to the server's `DIAL_API_KEY` and exports land in the server's single bucket; set `DIAL_AUTH_MODE=caller` if you'd rather exports fail loudly than fall back.
- **Template input**: the orchestrating agent passes the template to `create_presentation_from_template_content` as `file:data::files/{bucket}/{path}` — Quick Apps' file preprocessing resolves that reference to a data: URI before this server receives it (base64 via `file:base64::` also accepted). Note Quick Apps' default 10 MiB file-loading limit (`features.file_loading.size_limit`) if your templates are large.
- **Template instructions**: a template may ship with a markdown sidecar holding its own build rules — which slide is the section divider, the accent colour, the footer text. Pass it in the same call, as `instructions_content=file:data::files/{bucket}/{path}`; the server parses it, attaches it to the deck and returns its section list, and `get_template_instructions(presentation_id, section?)` serves it back on demand. Omit the argument when the template has no sidecar. See [Template instructions](#template-instructions).
- **Deck output**: `export_presentation` uploads to DIAL file storage and returns the `files/{bucket}/{path}` URL; the tool description instructs the agent to include it in its final answer.

## Images (orchestrator-generated)

The server does not generate images — it embeds them. The split is: **the orchestrator generates, the MCP inserts**, and only a short URL travels between the two.

| Tool | Use it for |
|---|---|
| `add_image_from_dial_url(presentation_id, slide_index, image_url, left?, top?, width?, height?, fit?)` | Anything the orchestrator produced with an image model. It calls the image deployment (a DIAL Core deployment sits next to the vision one), saves the result to DIAL file storage, and passes the `files/{bucket}/{path}` URL here; the server downloads the bytes itself with the caller's own DIAL credentials (same `DIAL_AUTH_MODE` resolution as export) |
| `manage_image(..., source_type="base64")` | Small assets only, and deployments not running under the DIAL host |

Prefer the URL tool. A 1024×1024 PNG is ~1–2 MB, so passing it as base64 pushes ~2 MB of payload through the agent's context on every insertion — enough to wreck the iteration budget on a multi-image deck.

**Placement is aspect-ratio aware.** `slide_index` is 0-based (like the other content tools; visual QA slide numbers are 1-based). Give `width` **and** `height` to define the box the picture should occupy, and `fit` decides how it relates to that box:

- `contain` (default) — largest undistorted size that fits, centred in the box. Safe for photos and illustrations.
- `cover` — fills the box exactly, cropping the overflowing edges symmetrically (python-pptx crop, no re-encoding).
- `stretch` — forces the exact box, distorting the image. Visual QA can move, resize and delete a picture, but it cannot un-distort one, so avoid `stretch` unless you mean it.

Pass only one of `width`/`height` to scale proportionally, or neither to keep the image's natural size — clamped to the slide, so a large generated PNG never hangs off the edge. The response reports the geometry actually applied (`"placed"`), which under `contain` may be smaller than the box you asked for; use it to lay out the text beside the image. A half-and-half slide on a 13.33in deck is text at `left=0.8, width=5.6` and the picture at `left=6.9, top=1.2, width=5.6, height=4.5`.

**Telling the agent to use it.** Image generation is the orchestrator's job, so it belongs in the Quick App's system prompt:

```text
When a slide would be stronger with a visual — a supporting image beside the
text, a cover image, an icon — generate it with the image model, upload it to
DIAL file storage, and pass the file URL the upload returned to
add_image_from_dial_url. Never paste image data into the conversation, and
never pass a public web URL — that tool only reads DIAL file storage. Give
width and height for the box you want it to fill and leave fit at "contain"
so the image is not distorted; for a text-left/image-right slide use roughly
half the slide width for each.
```

**How the server is allowed to read the file.** DIAL isolates deployments from each other: when the agent calls this toolset, DIAL Core mints a per-request key scoped to it, and that key can read exactly two places — its own bucket and `Users/{user}/appdata/{this-deployment}/`. A generated image lives in the *image* deployment's appdata (`Users/{user}/appdata/{image-deployment}/…`), which is neither. Conversation attachments are not auto-shared onto a toolset key either.

The bridge is a schema flag. Before each MCP call, Quick Apps scans the tool's input schema and, for every parameter marked `"dial_url": true`, asks Core to grant that specific file to the toolset's per-request key. `add_image_from_dial_url` declares it on `image_url`:

```json
"image_url": { "dial_url": true, "title": "Image Url", "type": "string" }
```

Two consequences worth knowing. **Pass the URL exactly as received, in that parameter** — the grant covers the value Quick Apps saw, so a URL reassembled by hand, or carried in some other argument, is not granted and still 403s. And this is why the flag matters more than `DIAL_AUTH_MODE`: without it no credential setting helps, because no identity available to this server owns that file. `get_dial_storage_info` reports the identity and bucket this server actually has, for when a read is still refused.

**What counts as an image URL.** A DIAL file reference: the `files/{bucket}/{path}` URL an upload returns, or the full https URL of that file on this DIAL installation — the `.../api/files/{bucket}/{path}` link an image deployment hands back is accepted as-is, as is the Core API's `/v1/files/...` form. Arbitrary web URLs are refused: this server is not a web fetcher, and an agent that finds a picture online must store it in DIAL file storage before inserting it. If your file links carry a different hostname than `DIAL_CORE_URL` (public chat host vs. in-cluster service), list it in `DIAL_PUBLIC_URL`.

Generated images are in scope for `visual_repair_slides` like any other shape. `DIAL_IMAGE_MAX_MB` (default 20) bounds what the server will download; non-raster input is refused with a message telling the agent to ask its image model for PNG or JPEG rather than SVG.

## Asset library (server-side images)

Some pictures belong to the deployment rather than to a user: a brand's icon set, a logo variant. Attaching them to every Quick App means every file has to be shared with every user who runs it. Instead, mount them into the pod and point `PPT_ASSET_PATH` at the folder.

| Tool | What it does |
|---|---|
| `list_assets(query?)` | Sorted image names in the library (`.png`, `.jpg`, `.jpeg`, `.gif`; hidden files, symlinks, folders and other files are ignored), optionally narrowed by a case-insensitive substring |
| `add_asset_to_slide(presentation_id, slide_index, name, left?, top?, width?, height?, fit?)` | Places one entry, with the same geometry and `fit` modes as `add_image_from_dial_url` (`contain` by default; give a square box for an icon) |

The agent passes a **name**, never a path: a name must be a bare file name that `list_assets` returns, so `../`, absolute paths and sub-folders are refused and the tool cannot read anything else on the pod. `DIAL_IMAGE_MAX_MB` bounds the file size here too. The folder is read on every call, so replacing its contents takes effect without a restart.

Name the files so a query by concept finds them — e.g. `brand_icon_<concept>_<variant>.png` — and tell the agent the naming scheme and when to use each variant in its system prompt.

A template on disk works the same way: put `deck.pptx` and its instructions sidecar `deck.md` in a `PPT_TEMPLATE_PATH` folder and the agent loads both with `create_presentation_from_template("deck.pptx")`. On Linux the sidecar name is case-sensitive.

## Icons (agent-drawn SVG)

Icons are the one visual an image model is bad at and a stock library only half-solves. The library problem is background: a stock icon file carries an opaque white background, so the moment a card is tinted or a panel is brand blue it shows as a pale rectangle — and no library has "regulatory submission" anyway.

So icons follow the same split as images, with one extra step. **The orchestrator draws, the server renders and checks, the orchestrator places.**

| Tool | What it does |
|---|---|
| `get_icon_guidance()` | Serves [docs/ICON_GUIDANCE.md](docs/ICON_GUIDANCE.md): when a drawn icon is the right answer, the two variants (line art for a light surface, white-on-filled disc for a coloured one), the rules that keep a set coherent, and eleven worked path examples to adapt |
| `render_svg_icon(svg, concept?, size?, background?, slide_background?, review?)` | Validates and rasterizes the SVG to a transparent PNG (PyMuPDF), has the vision model review the render, and returns an `icon_id` |
| `add_icon_to_slide(presentation_id, slide_index, icon_id, left?, top?, size?)` | Places a rendered icon on a slide, square and centred in a `size`-inch box |

**Why the render is reviewed before it is placed.** A hand-written path is valid XML long before it is a recognisable pictogram: an unclosed subpath fills into a blob, a stray coordinate leaves a hairline across the canvas, a mistyped `viewBox` puts the drawing off the edge. The agent cannot see any of that, and by the time visual QA meets the icon on a slide the repair whitelist offers only move, resize and delete — the deck loses the icon rather than getting a correct one. So the icon is reviewed alone, at full size *and* downscaled to the ~1in it will actually occupy, composited onto `slide_background` so contrast is judged against the real surface. **A failed review keeps nothing**: there is no `icon_id` to place, and the agent is told to fix the SVG and call again. Two cheap failures beat one broken slide.

Two checks run before the model is asked anything, because they name the defect more precisely than a verdict can: a render with no ink at all is refused outright ("the shapes are probably outside the viewBox"), and one that is nearly blank or nearly solid comes back with a `render_note` saying which.

**Input is untrusted.** The SVG is written by a model and parsed in this process, so `render_svg_icon` refuses DOCTYPE/ENTITY declarations (the XXE shape), `<script>`, `<foreignObject>`, embedded `<image>`, event-handler attributes, and any `href`/`url()` that leaves the document — an SVG that fetches is an SSRF primitive just like a URL parameter. Text elements are refused too, for a second reason: glyphs would come from whatever font the rasterizer substitutes, which is exactly the artifact class this feature exists to catch. Line art only — `<path>`, `<circle>`, `<rect>`, `<line>`, `<polyline>`, `<polygon>`.

**The icon does not go through DIAL file storage, and that is not an optimisation.** A file this server writes lands in `{user}/appdata/dial-pptx-mcp/`, which only the end user and this deployment may read. Placing it again would mean the *orchestrator* asking DIAL Core to grant that file to the toolset key before the call — and the orchestrator is neither of those two identities, so Core refuses with `403 Access to resource is forbidden` before the tool is even entered. (Exports do not hit this: their URL goes to the end user, who owns the bucket. An image-model PNG does not either: it arrives as a conversation attachment the orchestrator can see and therefore share.) The bytes were rendered in this process, so they simply stay here, under an unguessable `icon_id` handle with the same TTL and LRU bounds as a presentation handle — `PPT_MCP_MAX_ICONS` caps how many.

Rendering is PyMuPDF, already a dependency for the QA rasterizer, so icons work wherever the server runs — no LibreOffice, no new native library. Only the review needs a vision model; without one the icon still ships, with a note saying it was not checked. That check follows the model, not `VISUAL_QA_ENFORCE`: switching off slide inspection and repair is a different decision from whether one icon can be looked at.

**Telling the agent to use it.** Drawing is the orchestrator's job, so it belongs in the Quick App's system prompt:

```text
When a slide needs an icon — a row of cards, the steps of a process, a section
marker — do not ask the image model for one. Call get_icon_guidance once, write
the icon yourself as SVG following it, and pass it to render_svg_icon with
concept set to what it depicts and slide_background set to the colour it will
sit on. If the response says the review did not pass, fix the SVG as the issues
say and call it again; do not place an icon that has no icon_id. Then place
the icon_id with add_icon_to_slide (not add_image_from_dial_url — the icon is
not in DIAL storage), about size=0.7 in a card and 1.0-1.2 beside a section
title. Draw all of a deck's icons in one style, and reuse an icon_id wherever
that icon repeats. If the user's template already has icons for what you need,
use those instead.
```

## Building a deck

These tools cover the parts of deck construction upstream had no route to.

### Slide structure

python-pptx's only entry point is `slides.add_slide(layout)`, which appends a bare slide built from a layout. A corporate template's *designed* slides — its artwork, panels, logo placement, the three-card row someone laid out — live on the slides themselves and cannot be reached that way.

| Tool | What it does |
|---|---|
| `duplicate_slide(presentation_id, slide_index, insert_after?, count?)` | Copies a slide with all of its content and formatting. Pictures are shared (same bytes); charts, SmartArt and embedded objects are cloned, so editing the copy's chart does not rewrite the original's. Speaker notes travel with it |
| `delete_slide(presentation_id, slide_index)` | Removes a slide and its package relationship |
| `move_slide(presentation_id, slide_index, new_index)` | Reorders |
| `manage_shape(presentation_id, slide_index, shape_index, operation, ...)` | One element on a slide: `delete`, `clear_text`, `set_geometry` (inches), `bring_to_front` / `send_to_back`, `move_to_slide` (keeps position; a picture's or chart's parts move with it). `get_slide_info` lists each element's index, geometry in inches and a text preview. Deleting a layout placeholder empties it instead, since the layout would otherwise show its prompt text |
| `copy_slide_between_presentations(source, slide_index, target, ...)` | Merges decks. Everything the slide references is cloned into the target package; inherited theme colours and fonts re-resolve against the *target* master, so the copy is worth inspecting |
| `manage_speaker_notes(presentation_id, operation, slide_index?, text?)` | `get` / `set` / `clear`. Notes belong in the notes pane — a "notes" textbox is visible to the audience |

The recommended flow for template work is **duplicate, then fill**: find the template slide whose structure fits the content, duplicate it, and replace the text.

### Structural validation

`validate_presentation(presentation_id, min_severity?)` is the axis visual QA cannot see. A deck with a dangling relationship or a chart with no series renders in LibreOffice and opens in python-pptx — the two things the visual pass relies on — and still arrives broken.

It checks the package (round-trip, content types, relationship resolution, slide ids, notes parts shared between slides, orphan parts) and the slides (shapes off the canvas or zero-sized, charts with no data or mismatched series lengths, empty tables, pictures stretched off their aspect ratio, leftover placeholder text such as `Lorem ipsum` / `Click to add title` / `TODO` / `[insert ...]`). Each problem names the slide, the shape, what is wrong and the tool that fixes it.

Severities: `error` (PowerPoint may refuse the file), `warning` (a defect the user would notice), `info` (advisories — notably the font caveat below). It runs on every export too, as a non-blocking `"structure"` summary, and it is fast: no rendering, no model call.

Empty placeholders are deliberately **not** reported: PowerPoint draws their prompt text only in edit view, so they are invisible in a slideshow and in the PDF, and a template has dozens.

### Design guidance

`get_design_guidance(section?)` serves [docs/DESIGN_GUIDANCE.md](docs/DESIGN_GUIDANCE.md) — deck structure, layout and spacing, type scale, colour, charts and tables, images, the visual habits that make a deck read as machine-generated, and the build loop. Call it with no argument for the whole document plus the section list, or name a section (`type`, `colour`, `layout`, …) mid-build.

Its first section is the one that matters most here: this server's default case is a corporate template, so the right move is to **inherit** the user's design and duplicate their slides, not to invent a palette over the top of their brand.

### Template instructions

`get_design_guidance` is the same document for every deck. What it cannot carry is the part that differs per template: which of *this* template's slides is the section divider, which colour is the accent, what the footer must say, which slides must never be touched. Putting that in the orchestrator's system prompt works for one template and stops scaling at ten — every template's rules are then resident in every conversation, whichever template is actually in use.

So the rules travel with the template. Store a markdown file beside the `.pptx` and pass it to the tool that loads the template:

| Loader | How the sidecar is found |
|---|---|
| `create_presentation_from_template_content(template_content, instructions_content?)` | Passed explicitly, the same way as the template (`file:data::files/{bucket}/{path}`, a data: URI, base64, or the markdown inline) |
| `create_presentation_from_template(template_path)` | Looked up automatically beside the template on disk: `deck.pptx` → `deck.md`, falling back to `deck.pptx.md` |

The load returns a `template_instructions` object — the section list and character count, not the document itself — and `get_template_instructions(presentation_id, section?)` serves it whole or one `## Title` section at a time, from the deck's own entry in the store.

**A missing sidecar is the ordinary case, not an error.** No `instructions_content`, an unresolved `file:data::` reference, an empty file or a broken one: the template loads exactly as before and the response says why nothing was attached. The deck is the deliverable; the instructions are an enhancement.

Two things to know about the document itself:

- **Give it `## Title` headings.** Section retrieval is what makes re-reading cheap, and re-reading is the point: on a long build the first read scrolls out of the model's context long before the last slide is done, so the system prompt should tell the agent to re-read the relevant section before each slide rather than trust its memory of it. Numbered headings (`## 1. Slides`) work too. A document with no headings is served whole.
- **It is capped at `TEMPLATE_INSTRUCTIONS_MAX_KB` (default 128 KB) and must be UTF-8 text.** It comes out of a user bucket rather than shipping with the server, so it is served under a note telling the agent it is deck styling guidance and not a change to how it uses its tools.

### Slide previews

`render_slide_previews(presentation_id, slides?, describe?, columns?)` renders the deck into labelled contact sheets, uploads them to DIAL storage (so a person can look at them), and — since the agent cannot see an image — has the vision model describe what each slide is structurally suited to. Use it right after opening a template: layout names and indices cannot tell you which of eight near-identical layouts holds the three-card row. Registered only where LibreOffice is present.

### The finished-deck summary card

`render_deck_summary_card(presentation_id, title?, columns?, filename?)` tiles **every** slide into a single labelled JPEG, uploads it to DIAL storage and returns the `image_url`. It is meant for the end of the job, attached beside the exported `.pptx`: the user sees the whole deck in the chat without downloading a file and opening PowerPoint.

It is deliberately the mirror image of `render_slide_previews`. That tool is for the agent — several sheets, capped at 24 slides, and the part that matters is the vision model's description of each slide, because the agent cannot see a picture. This one is for the person: always exactly one image, no cap and no vision call. The grid's column count is fitted to the deck's length so a 60-slide deck comes back roughly landscape rather than as a tall stripe, and cells shrink to keep the card inside 2000×2600px — but never below 150px wide, since a card that scrolls beats one whose thumbnails are unreadable. Registered only where LibreOffice is present.

### Charts

`add_chart` builds one chart group: every series the same type, on one value axis. `add_combo_chart` covers what that cannot — bars with a target line across them, or two measures whose units differ so much that one flattens to nothing on a shared axis:

```json
{"categories": ["Q1", "Q2", "Q3"],
 "series": [
   {"name": "Revenue", "values": [10, 12, 15], "type": "column", "color": [31, 73, 125]},
   {"name": "Margin",  "values": [0.21, 0.23, 0.22], "type": "line_markers",
    "secondary_axis": true, "number_format": "0.0%", "data_labels": true}]}
```

The result is a real editable PowerPoint chart, not a picture — the embedded workbook and category caches are preserved. `format_chart_series` restyles one series of any existing chart: brand colour, data labels and their position, number format, trendline (`linear`, `movingAvg`, `exp`, `log`, `poly`, `power`).

### Output formats

`export_presentation(presentation_id, filename?, format?)` takes `format="pptx"` (default), `"pdf"`, or `"both"` — the pair is usually what a user asking to "share" a deck wants. The `.pptx` stays first in `files` and in the flat `file_url`. A PDF that cannot be rendered is reported as a note beside the delivered deck rather than a failed export.

`create_presentation_from_template_content` accepts a PowerPoint 97-2003 `.ppt` and converts it on the way in. Both conversions need LibreOffice.

## Visual QA (agent-driven inspect and repair)

When a vision LLM is configured (`VISION_LLM_*`), the server registers two tools the orchestrating agent calls whenever it wants — typically right after building each slide, not only at the end:

| Tool | What it does |
|---|---|
| `visual_inspect_slides(presentation_id, slides?, focus?, reference_presentation_id?)` | Renders the selected slides (LibreOffice → PDF → PNG) and runs the full review (see below). Read-only: returns `{"passed", "issues": [{slide, severity, category, element, description, suggested_fix, check}], "slides_reviewed", "checks"}` |
| `visual_repair_slides(presentation_id, slides?, focus?, max_iterations?)` | Reviews, then **repairs the deck itself** and re-reviews, looping until the slides pass or the budget runs out. The LLM is shown the issues, the affected (and related) slides' structure and their images, and returns a plan of whitelisted operations — move/resize, set/fit font size, autofit, set/clear text, font colour, word wrap, delete, bring to front/send to back, **move a shape to another slide**, **reorder slides**, table column width/row height/cell text, chart legend, data labels and axis titles — validated and applied with python-pptx. What no operation can fix (content that was never built) comes back as `action_required` |

`slides` is a list of 1-based slide numbers; omit it to work on the whole deck. Issue slide numbers are always absolute deck positions, even when only a subset was rendered, and a scoped repair call never touches a slide outside `slides`. Because LibreOffice converts the whole deck either way, a narrow selection saves the vision call and the repair round, not the render.

**A round never leaves a slide worse than it found it.** Each re-review judges every slide the last round touched against that slide's verdict before the round (critical issues weigh more than major ones). A slide that got worse is rolled back — the pre-round deck is restored and the round's other operations are replayed, so the slides that improved keep their fixes — and the round reports `reverted_slides` and `operations_reverted`. A slide reorder that makes the deck worse is undone whole. The operations themselves refuse the usual ways a fix goes wrong: a resize may not shrink a chart, table or picture below half its width or height, and a move or resize may not push a shape that sits on the slide off it. More rounds are therefore not the cure for a repair that made things worse; the default of 3 is deliberate.

`max_iterations` counts inspections, the first included; it defaults to `VISUAL_QA_MAX_ITERATIONS` (3) and can be lowered per call to 2 (one repair and a re-check) for a quick single-slide pass. Lower values are raised to 2, since a budget of 1 would inspect and never repair. A `"passed": false` result is a report, not a retry request: the agent should do what `action_required` says with the editing tools and repair those slides again, or tell the user what remains.

**What the checklist covers is the server's decision, not the agent's.** `focus` is optional and only ever *added* to the full checklist; an orchestrator does not need to — and should not try to — enumerate what to look for.

**"passed" is the server's verdict.** It is true only when no `critical` or `major` issue remains anywhere in scope, whatever the model's own `passed` flag says (models set it beside a critical finding often enough to matter). A repair result always carries `issues` (blocking, still open), `minor_issues` (reported, never repaired), each round's `changes`, `slides_reviewed` and `checks` (`visual`, plus `coherence` on whole-deck calls), so a pass can be audited rather than trusted.

### Export

`export_presentation` does **not** run QA. It reports what it knows — `"visual_qa": "passed" | "unverified" | "unavailable"` — and adds a note when the deck was never inspected or was edited since its last passing inspection. Only a clean whole-deck inspection marks a deck `passed`; a scoped call clears nothing.

Operators who want the old guarantee that no unverified deck ever leaves the server set `VISUAL_QA_EXPORT_GATE=true`: export/save then run the whole-deck inspect-repair loop for dirty decks and refuse the export if it cannot reach a pass. With the gate on, `VISUAL_QA_ON_UNRESOLVED` chooses `report` (default — fail the export with the unresolved issue list) or `export_as_is`, and `VISUAL_QA_ON_ERROR=allow` lets exports through when inspection itself cannot run (renderer/LLM outage — default blocks). Both variables are inert while the gate is off.

### What the reviewer checks

Two reviews run on a whole-deck call, in parallel:

- **Visual**, per slide, in batches of `VISION_LLM_BATCH_SLIDES` images sent at high detail. Beside each image the reviewer gets that slide's inventory from the file — title, each element's index, kind and text, and which text is *drawn over* by a later opaque shape — so it can tell text that exists but cannot be seen (a divider title under cards) from text that was never there, and content that does not match its title.
- **Coherence** (`deck_review.py`), one text-only call over an outline of every slide: agenda/index items against the actual section dividers (missing, extra, out of order, misnumbered), content that belongs to another slide's title (the off-by-one `slide_index` signature: one slide blank, its neighbour carrying its cards), blank content slides, hidden text, duplicated slides, the same figure quoted two ways, narrative order, title consistency, and text quality (typos, typed bullets, literal markup, mojibake).

The visual checklist covers blank or near-blank content slides, content that does not match the slide title, placeholder/sample text, hidden or stray elements, broken characters and doubled bullets, squashed or default-labelled charts, distorted or badly cropped pictures, inconsistent icons, elements crossing the template's header rule or footer, and misaligned comparable elements — as well as brand fidelity (colors, fonts, logo placement, layout usage) and **text placement and overlap wherever text is rendered**:

- **Text boxes and placeholders** — overflowing, clipped, or spilling past the slide edge; text overlapping other text or sitting unreadably on top of shapes and images; unfilled placeholders; text too small or too low-contrast to read.
- **Charts and graphs** — axis tick labels colliding with each other or truncated, data labels overlapping their bars/slices or each other, a legend covering the plot area, an axis title rotated into illegibility.
- **Tables** — cell text wrapping into an unreadable stack or clipped by the row height, columns too narrow for their content, headers misaligned with their columns, a table running past the slide.
- **Diagrams, SmartArt and grouped shapes** — labels wider than the node that holds them, text escaping a connector, node labels overlapping their neighbours.

It also flags text sized badly for the space it occupies — a heading set so small its box is mostly empty, or comparable elements at visibly different sizes — while being told not to ask for bigger text where growing it would eat the slide's white space.

Overlapping or unreadable text is graded at least `major`, so it fails the verdict rather than being noted in passing.

**Fitting text to its box.** The `fit_text` operation sizes text to the space it actually has, in both directions: it shrinks text that overflows and grows text that leaves its box mostly empty. The size is computed server-side from the box geometry (minus the frame's own margins, with a slack factor so text never touches its border) rather than guessed by the model, and the plan can bound it with `min_pt`/`max_pt`. Growth is anchored to the deck's own typography — at most 1.5× the shape's current size, or 44pt when the text inherits its size from the layout — so a two-word box cannot balloon to 96pt and shout over the slide. `set_autofit` sets PowerPoint's own autofit behaviour (`shrink_text`, `grow_shape`, `none`) when that suits the shape better. The size estimate is geometric, not a real text layout; the loop's re-render and re-review is what confirms it.

**Structure and content repairs.** `move_shape_to_slide` moves an element — with its picture or chart part, and with fresh shape ids — to the slide whose title it matches; a placeholder's text moves into the target's matching placeholder instead. `reorder_slides` takes the whole new order and is applied after every shape operation in the plan. An out-of-sync agenda is rewritten with `set_text` from the section dividers' own titles. The planner is told never to invent facts or figures: a slide that is empty because its content was never built becomes an `action_required` entry saying what is missing. Shape indexes in a plan always refer to the slide as described, so a delete earlier in the plan cannot shift a later operation onto the wrong shape.

**Reading a round that changed nothing.** `operations_applied: 0` with `operations_skipped: N` does not mean repair is disabled — it means the plan was rejected by validation. Each round now reports `skipped_reasons` (e.g. `{"bad shape_index": 2}`) and the result carries a `repair_note` explaining that repeating the call will not help. The common reason is `bad shape_index`: the fix targets something that is not a shape on the slide — typically a slide-number, footer or date placeholder inherited from the layout or master, which the repair engine cannot reach. Such reports are also often renderer artifacts (LibreOffice wraps a narrow slide-number field that PowerPoint lays out on one line), so check the deck in PowerPoint before chasing them.

**Chart axis titles.** `set_axis_title` names axes by role — `category` and `value` — not by screen position, and the planner is told which way round they sit for the chart type in hand. This matters because `add_chart`'s `x_axis_title`/`y_axis_title` map to the category and value axes regardless of orientation: on a **bar** chart the category axis is vertical, so titles chosen by where they appear on screen come out swapped. The tool's docstring now says so, and the repair op can correct it after the fact.

The repair engine can act on all of it: `describe_slides` hands the planner each table's column widths, row heights and cell text, and each chart's type, categories, series count and label/legend state — so a plan can widen a column, raise a row, retitle a cell, shrink a whole table's or chart's font, hide crowded data labels, or move the legend, instead of only nudging the container. Members of a group are not individually addressable; the group is moved, resized or shrunk as a whole.

### Fonts, and what a QA verdict can prove

The renderer is LibreOffice, which does not have Microsoft's fonts and substitutes its own. Some substitutions are *metric-compatible* — identical character widths, so a line that wraps in the render wraps identically in PowerPoint:

| Font in the deck | Rendered as | Text-fit verdict |
|---|---|---|
| Arial, Helvetica | Liberation Sans | exact |
| Times New Roman | Liberation Serif | exact |
| Courier New | Liberation Mono | exact |
| Calibri | Carlito | exact |
| Cambria | Caladea | exact |

Everything else (Georgia, Verdana, Trebuchet MS, Segoe UI, Garamond, Consolas, …) is substituted by similarity, and the widths differ — so a QA screenshot can show text overflowing a box that fits in PowerPoint, or fitting one that will not.

The server handles this rather than ignoring it: `validate_presentation` reports a deck's non-metric fonts as an `info` problem, and the review prompt carries a caveat telling the reviewer to report only clear, substantial overflow for text in those fonts and to judge everything else normally. In template mode this is a note on how to read the results, not a defect — the brand's fonts win.

### Telling the agent to use it

Nothing forces the orchestrator to inspect: with the export gate off, `export_presentation` reports `"visual_qa": "unverified"` but still succeeds. Put the workflow in the Quick App's system prompt so QA actually happens:

```text
Before planning the deck, call get_design_guidance. If the user supplied a
template, pass its .md sidecar as instructions_content when you load it and
read the result with get_template_instructions; re-read the relevant section
before you build each slide, since those rules win over the general guidance.
Call render_slide_previews to see its slides, and build by
duplicating the template slides that fit your content (duplicate_slide)
rather than adding bare ones.
Build every slide first. Then call validate_presentation and fix any errors
it reports, then call visual_repair_slides once with no slides argument and no
focus — the server's checklist is complete. It reviews every slide and the
deck's story (agenda, misplaced or missing content), repairs what it can and
re-checks — do not call visual_inspect_slides before it. If it returns
"passed": false, do what each action_required entry says with the editing
tools and call visual_repair_slides again with just those slides. If the export response says
"visual_qa": "unverified", say so in your answer rather than presenting the
deck as checked.
After exporting, call render_deck_summary_card and attach the image it returns
alongside the .pptx, so the user can see the finished deck in the chat.
```

One whole-deck `visual_repair_slides` at the end is the cheap path — one render, one parallel set of vision batches and one coherence call, then follow-up rounds on the changed slides only. It is also the only thing that marks the deck `passed`, and the only scope that runs the coherence review, so it is the only call that catches an agenda out of sync or content built onto the wrong slide. Per-slide calls cost an orchestrator turn plus a render and a vision round trip each; keep them for the first slide you build from a new layout, where catching a mistake before you copy it pays off.

### Sizing the QA work (orchestrator budget, timeouts, pod resources)

| Concern | Guidance |
|---|---|
| Orchestrator iterations (Quick Apps `max_iterations`, default 15) | Now includes the QA calls the agent makes. Roughly 2 calls per slide plus create/export, plus one inspect or repair per slide: a 20-slide deck needs **~65**, so set `max_iterations` to **80** (100 if slides carry charts/tables/images) |
| Tool timeout (Quick Apps `tool_defaults.timeout_seconds`, default 300s) | Rendering is ~1–2s for a few slides and ~4s for a 10-slide deck (only the layouts in use are rendered, however many the template carries); the rest is vision-model latency. A whole-deck `visual_repair_slides` is one parallel review (batches of `VISION_LLM_BATCH_SLIDES`, plus the coherence call), then one plan + one re-review per repair round on the changed slides only, at most `VISUAL_QA_MAX_ITERATIONS` (3) rounds. Measured: an 8-slide deck with three structural defects took ~90s for three rounds with a reasoning model. Raise the timeout to 600s for 20+ slide decks |
| Slides actually reviewed | `VISION_LLM_MAX_SLIDES` (default 60) caps whole-deck calls only and anything past it is reported in `slides_not_reviewed`; an explicit `slides` list is never truncated |
| Pod resources | LibreOffice renders in-pod: budget **1 CPU / 2Gi** with a writable `/tmp`. Small limits (e.g. 192Mi) get the renderer OOM-killed, which fails every QA call |

`VISUAL_QA_ENFORCE=false` registers neither tool and turns slide QA off (the icon review in `render_svg_icon` is unaffected — it follows the model's presence). The reviewer model can be reached two ways: a direct OpenAI Responses-API endpoint with image input (Azure OpenAI included), or as a DIAL Core deployment via `{DIAL_CORE_URL}/openai/deployments/{model}/chat/completions` — see the `VISION_LLM_*` variables. Cost note: each inspect is one render plus one LLM call; each repair round adds a second LLM call.

## Logging

Every record is a **single line on stderr**, so `kubectl logs` shows one event per line and nothing wraps across lines:

```
2026-08-20T09:14:02.517Z INFO  dial_pptx.tools.presentation export_ok presentation_id=9f3c1a2b… filename=deck.pptx slides=12 bytes=1841203
```

`timestamp (UTC) · level · logger · message`, with details as `key=value` pairs. Multi-line content is folded onto the same line with ` | ` separators — that includes tracebacks, so a stack trace stays greppable instead of scrolling the pod terminal. FastMCP's default Rich handler (boxed, multi-line, colored) and uvicorn's separate log format are both replaced, so third-party output matches.

`LOG_LEVEL` sets the verbosity of this server and the libraries under it:

| Level | What you get |
|---|---|
| `ERROR` | Failures only: blocked exports, render/vision-LLM outages, upload failures |
| `WARNING` | The above, plus degradations that don't fail the call: QA rounds that found issues, LRU eviction, auth falling back to the server key, skipped repair operations |
| `INFO` (default) | One line per tool call (`tool_ok`/`tool_error` with `duration_ms`), presentation lifecycle, each QA round's verdict, exports and uploads, server startup |
| `DEBUG` | The above, plus per-call argument summaries, render and vision-LLM timings, individual QA issues and repair operations, template content-type coercion, and the underlying HTTP client's own logs |

Handles are truncated (`9f3c1a2b…`) and argument values are summarized by type and size (`template_content=<str:412880>`) rather than logged verbatim, so logs never carry a usable presentation handle, a base64 template, or slide text. Credentials are never logged.

Per-subsystem tuning is available in code: loggers are nested under `dial_pptx` (`dial_pptx.visual_qa`, `dial_pptx.tool.export_presentation`, `dial_pptx.utils.template`, …), so a single subsystem can be raised or lowered independently of `LOG_LEVEL`.

## Multi-tenancy and scaling notes

- Presentation handles are server-generated UUIDs and act as unguessable capabilities; clients cannot enumerate or guess other conversations' decks.
- Presentation state lives in process memory (bounded by TTL + LRU). Run a single replica, or use session affinity if you scale out — a deck created on one replica is not visible on another.
- Calls targeting the same presentation are serialized (python-pptx is not thread-safe); different presentations are handled concurrently.
- Every tool call runs on a worker thread rather than the server's single event loop, so an orchestrator's parallel tool calls (e.g. several `render_svg_icon` calls in one agent turn) genuinely overlap instead of queuing behind each other's blocking I/O. `PPT_MCP_MAX_CONCURRENT_TOOL_CALLS` bounds how many run at once, process-wide, so a burst of concurrent calls cannot flood the pod's CPU. Note that its default is derived from `os.cpu_count()`, which reports the *node's* CPU count rather than the pod's cgroup limit — set it explicitly when the pod's CPU limit is lower than the node's.
- LibreOffice conversions are bounded separately by `PPT_MCP_MAX_CONCURRENT_CONVERSIONS` (default 2) because they are memory-heavy rather than CPU-heavy: each `soffice` process peaks around 0.5 GB, so enough concurrent renders to satisfy a CPU-based cap can still exceed the pod's memory limit and make conversions fail intermittently ("source file could not be loaded"). Budget roughly 0.5-1 GB per slot on top of the deck store.

## Development

```bash
uv venv --python 3.12 .venv && uv pip install -r requirements.txt
.venv/bin/python -m unittest discover -s tests        # unit tests
.venv/bin/python spike/fidelity_spike.py <template>   # template-fidelity check
.venv/bin/python spike/http_client_check.py           # transport smoke test
```

`spike/fidelity_spike.py` creates a deck from a template through the same code paths the MCP tools use and byte-compares the theme, slide-master, layout, and media parts of template vs output. Local test templates belong in `templates-local/` (gitignored).

## Credits

This project is built on **[Office-PowerPoint-MCP-Server](https://github.com/GongRzhe/Office-PowerPoint-MCP-Server)** by [GongRzhe](https://github.com/GongRzhe), used under the MIT license, with full git history preserved. The core PowerPoint manipulation tools and utilities are upstream work; this fork adds the remote-transport, multi-tenant state, and DIAL integration layers. The original [LICENSE](LICENSE) and copyright notice are retained; the upstream README is preserved at [docs/UPSTREAM_README.md](docs/UPSTREAM_README.md).

## License

MIT — see [LICENSE](LICENSE).

TDQS

C2.9/5.0

Scored across 53 tools

Disambiguation2/5

Several tool clusters overlap heavily: four create_presentation* variants, multiple template-related tools, and text editing spread across manage_text, populate_placeholder, add_bullet_points, and optimize_slide_text. An agent would need to read long descriptions to reliably pick the right tool.

Naming Consistency3/5

Most tools follow a snake_case verb_noun pattern, but it is diluted by eight generic manage_* tools, three create_presentation_from* variants, and apply_* tools covering different concerns. The style is consistent, but the verb vocabulary is too broad to be truly predictable.

Tool Count2/5

53 tools is well into the heavy range even for a feature-rich PowerPoint server. Several families could be consolidated, and the sheer count adds significant selection burden for agents.

Completeness4/5

The surface covers presentation lifecycle, slide CRUD, text, tables, charts, images, icons, notes, validation, and export with no obvious dead ends. Minor gaps like editing an existing slide's background or adding media exist, but they are edge cases.

Maintenance

ActivityActive
ResponsivenessResponsive