Releases: photonn/dial-pptx-mcp
Release list
v2.7.0 — icons the agent draws itself
Template mode inherits the user's icons. Where the template has none — and especially where an
icon has to sit on a coloured card, which a stock PNG with its baked-in white background cannot —
the agent previously had nothing to reach for. This release lets it draw one.
Three tools, registered unconditionally (tools/icon_tools.py):
get_icon_guidanceservesdocs/ICON_GUIDANCE.md— the style contract: one stroke weight on a
200×200 viewBox,fill="none", rounded caps and joins, 6–8 segments, the two variants (line art on
light, white-on-filled-disc on colour), and eleven worked path examples. Read from disk and
mtime-cached, like the design guidance, so it costs nothing until a caller is building.render_svg_icontakes the agent's SVG, rasterizes it and judges it.add_icon_to_slideplaces the result.
The SVG is untrusted input
It is parsed in-process, so validate_svg refuses DOCTYPE/ENTITY (XXE), <script>,
<foreignObject>, <image>, event handlers, and any external href/url() (SSRF) before anything
touches a parser. SVG_ICON_MAX_KB (default 64) bounds the source — an icon is a handful of paths;
the limit is what stops traced artwork arriving as one.
<text> is refused too, for a different reason: a substituted glyph is exactly the artifact class
the review below exists to catch, and it would arrive pre-approved.
Rasterizing without a new dependency
PyMuPDF is already in requirements.txt for PDF export, and it reads SVG. So icons work on an
installation with no LibreOffice at all — which is not true of any other rendering path in this
server.
The review, and why the icon is judged alone
A hand-written path is valid XML long before it is a pictogram. The agent cannot see what it drew,
and the visual-repair whitelist can only move, resize or delete a picture — there is no operation
that fixes a malformed icon once it is on a slide. So the icon is reviewed before placement, on
its own: full size, and again downscaled to ~1in and composited onto the slide_background it will
actually sit on, because legibility at slide size is the property that matters and it is not visible
at 800px.
A failed review keeps nothing and returns no handle. An icon_id in the response means the icon
is fit to place, which is what makes the retry loop unambiguous — redraw and call again.
Two cheap guards run before the model is called at all: an empty render is refused with its likely
cause (usually a stroke with no fill="none", or geometry outside the viewBox), and
near-blank/near-solid ink coverage comes back as a render_note.
The review is gated on whether a vision model is configured, not on VISUAL_QA_ENFORCE. That
switch hides the slide inspect/repair tools; whether a model exists to look at one icon is a
different decision, and visual_qa.vision_configured() now expresses it separately from
enforcement_enabled().
The rendered PNG stays on the server
render_svg_icon returns an icon_id — a UUID handle into an in-memory IconStore with the same
TTL and LRU bounds as PresentationStore (PPT_MCP_MAX_ICONS, default 100 — roughly 4 MB) — and
add_icon_to_slide places from it.
Uploading the PNG to DIAL storage and passing its URL to add_image_from_dial_url is the obvious
design, and it does not work. A file this server writes lands in {user}/appdata/dial-pptx-mcp/.
Placing it means the orchestrator asking Core to grant that file to the toolset key — for a folder
it neither owns nor is. Core answers 403 before the tool is entered, so no DIAL_AUTH_MODE value
and no retry helps.
Exports and summary cards are unaffected: their URLs go to the end user, who owns the bucket. An
image-model PNG is unaffected: it reaches the conversation as an attachment the orchestrator can
share. Icons are the one case where the server is both producer and consumer, and the handle is what
keeps them out of that boundary entirely.
Upgrading
Nothing changes for existing decks or tools. Two new environment variables, both optional:
SVG_ICON_MAX_KB (64) and PPT_MCP_MAX_ICONS (100).
If you run this behind a DIAL Quick App, update its system prompt. The icon flow is
get_icon_guidance → render_svg_icon → icon_id → add_icon_to_slide; an icon handle is not a
DIAL file URL and must not be passed to add_image_from_dial_url. The README's Icons section has a
prompt block to paste.
v2.6.0 — render only the slides visual QA is looking at
Slide-scoped visual QA calls (visual_inspect_slides / visual_repair_slides with an
explicit slides list) still paid for a full-deck LibreOffice conversion on every render.
render_pptx_bytes_to_pngs's slides= argument only filtered pages out of the resulting PDF
after LibreOffice had already converted every slide — so a repair loop scoped to one problem
slide converted the whole deck up to VISUAL_QA_MAX_ITERATIONS times regardless.
_render_deck now builds a stripped copy of the deck first — reusing the existing OPC-level
delete_slide to drop every slide outside the requested scope — and hands LibreOffice only
that. The full deck is still converted for whole-deck inspections; only slide-scoped calls
change. Repair semantics are unaffected: apply_repairs still edits the real in-memory
presentation, the stripped copy is render-only and discarded after conversion.
Net effect: LibreOffice's per-conversion cost now scales with how much of the deck a call
actually asks about, not with deck size — most valuable in the inspect/repair loop, where it
was previously paid on every iteration.
v2.5.0 — the deck PowerPoint opens is the deck LibreOffice rendered
Visual QA renders through LibreOffice. The deck is delivered to PowerPoint. This release
closes three places where those two disagreed — and where, because the disagreement was
invisible to every check we run, the defect shipped.
All three have the same shape: python-pptx leaves a setting implicit, ECMA-376 reads the
omission as true, PowerPoint applies it and LibreOffice does not.
1. Charts no longer render two different ways
A single-series bar chart came out of LibreOffice as one blue series with one legend entry,
and out of PowerPoint with a colour per category, five legend entries, and the legend lying
on top of the bars.
Three omissions, all now written out:
c:varyColorsis absent from python-pptx's bar/column writers. PowerPoint reads the
missing value as true and colours a single-series chart per data point, listing the
categories in the legend instead of the series.chart.has_legend = Trueinserts a bare<c:legend/>— noc:overlay, so PowerPoint
lays the legend over the plot area, and noc:legendPos, which meantadd_chart's
legend_positionargument was accepted and then silently discarded. It works now.c:autoTitleDeletedis absent on an untitled chart, so PowerPoint invents a title from
the series name and LibreOffice shows none.
normalize_chart_defaults runs at the end of add_chart and states all of them, plus
plotVisOnly. add_combo_chart already built explicit XML and needed nothing.
2. Repaired placeholders stay where they were put
visual_repair_slides could move a shape to the top-left corner of the slide — but only in
PowerPoint, and only for placeholders that inherit their geometry from the layout.
Setting one dimension on such a placeholder makes python-pptx create an <a:xfrm> holding
only what was set: no <a:off> at all, or an <a:ext> with a zero extent. That partial
transform stops the inheritance dead. LibreOffice falls back to the layout and draws the
shape correctly, so the QA render looked right; PowerPoint reads it literally.
The reason this survived so long is that it was invisible from every angle: python-pptx
also reports the inherited value for the missing half, so shape.left returned the right
number, and the geometry validator saw a perfectly well-placed shape.
pin_inherited_geometry materializes all four values before the edit, and both move_shape
and resize_shape call it — move had the mirror bug, an a:off with no a:ext.
3. Structural validation catches the ones that slip through
New partial_transform warning: a half-written transform is reported wherever it came from,
with the consequence spelled out per case — a missing offset puts the shape in the corner, a
missing extent makes it vanish — and the fix naming move_shape or resize_shape to match.
This is squarely the axis visual QA cannot see, which is what deck_validation is for.
4. Scatter charts work
add_chart advertised 'scatter' as a valid type while building CategoryChartData for it.
That raises on every call, so the type had never once worked — it just came back as
{"error": "Failed to add chart: …"}.
It now builds XyChartData. A scatter chart has no category axis, so categories carries the
x values and they must be numbers; when they aren't, the tool returns an instruction that
names line_markers as the alternative, rather than a traceback string.
Upgrading: decks exported before this release still carry the old XML. Nothing in the
repair whitelist retrofits a varyColors or a missing a:off — the affected slides have to
be rebuilt. New builds are correct in both renderers.
Charts built after upgrading will look different from ones built before, in PowerPoint:
single-series charts lose their per-category colours, untitled charts lose the auto-title,
and the legend goes where it was asked to go. That is the fix, not a regression.
v2.4.0 — build from the template: slide lifecycle, design guidance, previews, validation
The biggest release since the fork. It closes the gap between this server and what a
capable PowerPoint agent needs, and it does so around one premise: the deck the user
gets should be built out of their template, not painted over it.
Everything here is an independent implementation. Nothing was copied from the skill this
work was benchmarked against.
1. Slides can finally be moved, copied and duplicated
python-pptx's only entry point is slides.add_slide(layout). There was no delete, no
reorder, no duplicate — so the only way to build was to add bare slides and reconstruct
the template's look by hand, which never quite matches.
delete_slide, move_slide, duplicate_slide, copy_slide_between_presentations and
manage_speaker_notes are implemented at the OPC level: <p:sldIdLst> edits for delete
and move, and for duplication a deepcopy of the slide XML plus a rebuilt relationship set
that preserves the source's rIds — the copied XML still carries them, so letting the
package renumber breaks every r:embed. Image, media and font parts are shared;
chart, SmartArt and embedded parts are cloned, or update_chart_data on a copy would
rewrite the original's chart.
This makes the intended flow possible at last: duplicate the template slide, then fill
it. A layout holds placeholders. It does not hold the template's artwork.
2. The agent is told what a good deck looks like
The tools answered "how do I place this". Nothing answered "what should this slide look
like". get_design_guidance serves docs/DESIGN_GUIDANCE.md — nine sections on
structure, layout, type, colour, charts, images, the habits that make a deck read as
machine-made, and the build loop — whole or one section at a time, so it costs nothing
until a caller is actually building.
Its first section is the one that matters: this server's default case is a corporate
template, so the right move is to inherit the user's design, not to invent a palette over
their brand.
3. Two ways to actually see the deck
render_slide_previews renders the deck into labelled contact sheets and has the vision
model describe what each slide is structurally for. Layout names cannot tell you which of
eight near-identical layouts holds the three-card row. The agent cannot look at a picture,
so the description is the half it can act on.
render_deck_summary_card is the mirror image, for the end of the job: every slide
tiled into one image, attached beside the exported file so the user sees the result
without downloading anything. No slide cap, no vision call, and because the stored image
is the deliverable there, a failed upload is an error rather than a note. The column
count is fitted per deck — a fixed grid turns 60 slides into an unreadable stripe — and
cells shrink to a pixel budget except when that would make them illegible, in which case
the card grows taller instead. A card that scrolls beats one you cannot read.
4. Structural validation, because QA cannot see everything
A deck with a dangling rId renders fine in LibreOffice, opens fine in python-pptx, and
still fails in PowerPoint. validate_presentation reports what the visual axis is blind
to, with severities (error = may not open, warning = user-visible defect, info =
advisory) and a fix on every problem naming the tool that resolves it.
Export folds in a structure summary but does not block on it. Refusing to deliver a
finished deck over a warning costs the user more than the warning does.
5. Fonts: QA now knows when to doubt itself
Visual QA renders through LibreOffice, so its text-fit verdicts are only trustworthy for
fonts with metric-compatible substitutes (Liberation Sans/Serif/Mono, Carlito, Caladea).
Everything else is substituted by similarity and the widths differ — a screenshot can show
text overflowing a box that fits in PowerPoint.
Non-metric fonts now raise an info problem in validation and append a caveat to the
review prompt telling the reviewer to report only clear, substantial overflow for text in
those fonts. The point is not to "fix" a template's fonts to satisfy QA. The brand wins;
the caveat is what makes that possible.
6. Combo charts, secondary axes, per-series formatting
add_combo_chart covers what a single chart group 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. format_chart_series adds colour, data labels, number formats and trendlines.
A plot area holds a list of chart-group elements, each naming its axis pair, so this
builds an ordinary single-type chart with every series — which is what writes a correct
embedded workbook and the shared category caches — then redistributes the c:ser elements
into per-(type, axis) groups. It never falls back to a rendered image: visual repair can
move or delete a picture, and nothing else.
One trap worth naming: chart.value_axis returns the second valAx when a chart has
two, because python-pptx assumes a scatter chart. Titling "the value axis" on a dual-axis
combo put the left axis' title on the right-hand one. Axes are addressed by id now.
7. PDF export and legacy .ppt input
export_presentation(format="pptx"|"pdf"|"both") — the pair is what a user who asked to
"share" a deck usually wants. .ppt uploads are converted on the way in, with an explicit
note that the conversion is approximate: the binary round-trip loses layouts, measured
11 → 1 on the demo deck.
Testing
302 tests, green on 3.10 and 3.12. LibreOffice was installed locally for this work, so
the renderer-dependent tests actually ran rather than self-skipping.
tests/test_render_integration.py puts the hand-assembled XML through a real renderer and
asserts on text extracted from the PDF, not image size. Its first version was vacuous:
a combo chart whose line group named an undeclared axis still passed an "is it blank"
check, because the columns render regardless. There is now a test that breaks the axis on
purpose and asserts the secondary tick labels disappear, so the class cannot silently go
vacuous again.
Full diff: v2.3.1...v2.4.0
v2.3.1 — the dial_url flag that makes image insertion work
The 403 that blocked add_image_from_dial_url was never a credentials setting. This
release adds the one thing that was missing: a flag in the tool schema.
Why no auth mode could have worked
When the agent calls this toolset, DIAL Core mints a per-request key scoped to
dial-pptx-mcp. That key can read exactly two places:
Keys/dial-pptx-mcp/— its own bucketUsers/{user}/appdata/dial-pptx-mcp/— its own appdata
A generated image lives in the image deployment's appdata —
Users/{user}/appdata/{image-deployment}/… — which is neither. The toolset call path does
not share conversation attachments onto that key either. So every read of a generated
image was refused, whatever DIAL_AUTH_MODE said.
The bridge
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 never emitted the flag. It does now:
DialFileUrl = Annotated[str, Field(json_schema_extra={"dial_url": True})]which the pinned SDK passes through to tools/list verbatim:
"image_url": { "dial_url": true, "title": "Image Url", "type": "string" }Using it
Pass the file URL exactly as received, in image_url. 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.
A test asserts the flag survives registration to tools/list. It is invisible in normal
use and silently fatal when missing, which is precisely the kind of thing that rots.
The 403 hint added in v2.2.0 now points at the grant rather than at auth mode.
Not flagged
The template tools take a local path or base64/data: content, not a DIAL file URL, so
the flag would grant nothing there. A template-by-URL tool would be a feature, not a fix,
and is not in this release.
Suite is 154 tests, green.
v2.3.0 — repairable axis titles, self-explaining repair rounds
Three fixes, all from reading real visual_repair_slides output where every round came
back "operations_applied": 0, "operations_skipped": 4.
1. Chart axis titles are repairable now
The whitelist covered chart legends, data labels and font size — but not axis titles. So
when the reviewer correctly reported "the axis titles are swapped", the planner had no
operation to express the fix and every attempt was rejected as an unknown op. The issue
could be found forever and never repaired.
{"op": "set_axis_title", "slide": N, "shape_index": N, "axis": "category", "text": "..."}Axes are named by role — category and value — never by screen position, and
describe_slides now reports each chart's current titles so the planner can see what it
is changing.
2. The swapped titles were this repo's own doing
format_chart maps x_axis_title → category axis and y_axis_title → value axis
unconditionally. On a column or line chart that reads as expected. On a bar chart the
bars run sideways: the category axis is vertical, so x_axis_title lands on the vertical
axis. Any agent naming axes by where they appear on screen gets them swapped every single
time — and add_chart's entire docstring was "Add a chart to a slide with comprehensive
formatting options."
The docstring now spells the mapping out, which fixes the defect at the source rather than
repairing it after the fact. Behaviour is unchanged: renaming those parameters would
silently break decks that use them correctly today.
3. A round that changes nothing now says why
"operations_applied": 0 reads as "repair is disabled". It never was — the plan was
rejected by validation. Each round now carries the reason counts:
{"iteration": 1, "issues_found": 2, "operations_applied": 0,
"operations_skipped": 4, "skipped_reasons": {"bad shape_index": 4}}and the result carries a repair_note explaining that repairs ran, that bad shape_index
usually means the target belongs to the slide layout or master (slide numbers, footers,
date fields — slide.shapes does not expose them, and no operation can reach them), and
that repeating the call will not help.
Worth knowing for that last class: a slide number rendering as stacked digits is often a
LibreOffice artifact, not a defect in the deck. Check it in PowerPoint before chasing it.
Suite is 153 tests, green.
v2.2.0 — diagnose refused DIAL file reads
v2.1.0 fixed the shape of DIAL file URLs. This release deals with what surfaces
underneath it: a refused read, which is an identity problem wearing a URL's clothes.
The failure
Failed to download the image from DIAL file storage:
Client error '403 Forbidden' for url
'http://dial-core.dial.svc.cluster.local/v1/files/PZh2…/appdata/gpt-image-1.5-square/images/027b….png'
Nothing there is actionable — not for the agent, which will retry or hallucinate another
URL, and not really for the operator either. The URL is correct. The credentials are the
problem.
DIAL file storage is per-user, and inside appdata it is also per-deployment: credentials
reach their own bucket and their own appdata folder, nothing else. An image generated by
an image deployment lands in {user-bucket}/appdata/{that-deployment}/…, so a server
falling back to its own DIAL_API_KEY cannot read it, and neither can a caller that is
not that deployment or the bucket's owner.
What a refusal says now
401/403/404 on a read raises DialConfigError naming the identity used, the bucket it
owns, the bucket the file is in, the appdata deployment that owns it, and the ways out:
DIAL Core refused this file (403). This read ran as the server's DIAL_API_KEY, which owns
bucket SERVERBU…, while the file is in bucket PZh2M4YF…. The file also sits in the
appdata folder of the 'gpt-image-1.5-square' deployment, which only that deployment and
the user who owns the bucket may read — this server is neither unless DIAL forwards the
end user's own credentials to it. Fix it at the source: have the orchestrator copy the
image into storage these credentials can reach…
New tool: get_dial_storage_info
Read-only. Reports the identity this server has on the current request, the bucket it
owns and its appdata path — so "can this server read that URL?" is a question you answer
by looking, not by triggering a failure. If the bucket it reports is not the bucket in the
image URL, no URL fix will help.
Not added
Retrying a refused read under the server's key after the caller's credentials were denied.
It would "fix" this class of failure by turning an authorization error into privilege
escalation, and it is now written down in CLAUDE.md as a thing not to do.
Suite is 146 tests, green.
v2.1.0 — accept every DIAL file link form
add_image_from_dial_url now accepts the file links DIAL actually hands out.
The bug
An image deployment returns its result as a link like
https://<dial-host>/api/files/{bucket}/appdata/<model>/images/<hash>.png
DialFileClient.download understood only the relative files/{bucket}/{path} form and
its own /v1/files/... URL. Anything else was appended to the base URL wholesale, so the
chat frontend's proxy path turned into /v1/files/api/files/... and 404'd — the one link
shape an orchestrator is most likely to have.
The fix
_file_request_url normalizes every DIAL file reference — relative or absolute,
v1/files/... or api/files/... — and always rebuilds the request against
DIAL_CORE_URL. A link's host is therefore only ever an authorization question, never
where the bytes come from.
New DIAL_PUBLIC_URL (comma-separated URLs or hostnames) lists this installation's public
aliases, for the common deployment where the server reaches Core in-cluster while the
orchestrator holds https://chat.example.com/api/files/... links.
Better refusals
Foreign hosts are still refused — an MCP tool that fetches arbitrary URLs is an SSRF
primitive, and that will not change. But the refusal now names the host and tells the
agent what to do instead:
Refusing to download from upload.wikimedia.org: this server reads files from DIAL file
storage only, never arbitrary web URLs. Save the file to DIAL file storage first and
pass the 'files/{bucket}/{path}' URL that upload returned.
The old message — "Refusing to download from a host other than DIAL_CORE_URL." — gave a
model that had just passed a wikipedia.org URL nothing to act on. The tool description now
states the same rule up front, so the orchestrator should not try in the first place.
The reference is also validated before credentials are resolved: a bad URL is reported
as a bad URL instead of as a missing API key.
Suite is 144 tests, green.
v2.0.1 — honest get_server_info
Housekeeping release: get_server_info now describes the server that is actually running.
What was wrong
The tool was inherited from upstream and never updated. It reported "version": "2.1.0"
— upstream's number, unrelated to this fork's line, which starts at 1.0.0 — and
"total_tools": 32, while 40 tools register today. Worse, the count cannot be a
constant: registration is dynamic, and the visual QA tools appear only when a vision LLM
is configured. It also carried three lists of "features", "improvements" and
"new_enhanced_features" that were marketing prose rather than facts about the process.
What it reports now
{
"name": "dial-pptx-mcp (PowerPoint MCP Server for EPAM AI DIAL)",
"version": "2.0.1",
"upstream": "GongRzhe/Office-PowerPoint-MCP-Server",
"tools": 40,
"loaded_presentations": 0,
"visual_qa": "tools",
"dial_file_storage": "configured"
}Every field is derived at call time: the version from pyproject.toml via
_project_version() (parsed with a regex rather than tomllib, since CI still runs
3.10), the tool count from the live registry, and the integration state from the same
helpers that produce the startup log line. The tool is also annotated readOnlyHint,
which it always should have been.
Compatibility: the total_tools, features, improvements and
new_enhanced_features keys are gone. Nothing in this server or its docs consumed them.
Four tests (tests/test_server_info.py) pin the version to pyproject.toml and the count
to the registry, so neither can drift again. Suite is 139 tests, green.
v2.0.0 — generated images, inserted by DIAL file URL
Decks can carry generated imagery now. The server does not generate it: the orchestrator
calls its own image deployment, stores the result in DIAL file storage, and hands this
server a short file URL to embed.
The new tool
add_image_from_dial_url(presentation_id, slide_index, image_url,
left=1.0, top=1.0, width=None, height=None,
fit="contain")
The bytes are fetched server-side through DialFileClient, with the same
DIAL_AUTH_MODE resolution as export — in the default auto mode that means the end
user's own credentials and their own bucket.
Why not base64
manage_image(source_type="base64") already existed and still does, but a 1024×1024 PNG
is ~1–2 MB: passing it inline pushes roughly 2 MB of payload through the agent's context
on every insertion, which wrecks the Quick Apps iteration budget on a multi-image deck.
The URL form moves a ~50-character string instead. Keep base64 for small assets and for
deployments not running under the DIAL host.
Placement is aspect-ratio aware
slide_index is 0-based, like the other content tools. Give width and height to
define the box the picture should occupy; fit decides how it relates to that box:
contain(default) — largest undistorted size that fits, centred in the box.cover— fills the box exactly, cropping the overflowing axis symmetrically via
Picture.crop_*(no re-encoding).stretch— forces the exact box, distorting the image.
Pass one of width/height to scale proportionally, or neither to keep the 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 requested — use it to lay out the text beside the image.
The non-distorting default is deliberate: visual_repair_slides can move, resize and
delete a picture, but there is no whitelisted operation that un-distorts one.
Guards
Handle, slide index, fit mode and box dimensions are validated before the download.
DIAL_IMAGE_MAX_MB (default 20) bounds what will be fetched and embedded, and non-raster
input is refused with a message telling the agent to ask its image model for PNG or JPEG
rather than SVG.
Why 2.0.0
No breaking change to existing tools — the major bump marks the scope change: the server
now also places orchestrator-generated media, not only authored content. Image generation
itself stays a Quick Apps concern.
Docs: new "Images (orchestrator-generated)" section in the README, including a drop-in
system-prompt snippet for the Quick App, plus DIAL_IMAGE_MAX_MB in the config table and
.env.example.
16 new tests (tests/test_image_tools.py); suite is 135 tests, green.