Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ snapshot commit; the development history before it is summarised here.

## Unreleased

- Chart formatting (production feedback round 3, G1-G4; ooxml-edit 0.15.0): a chart's axis
scale (a 1-5 maturity axis by 1, categories top-down for a ranked bar chart), gridlines,
series and point colours, lines and markers, a column series drawn as a line (target
markers over actual columns), chart text, and points labelled with their own text --
optionally from the workbook, as PowerPoint's Value From Cells -- through `Chart` and
`edit_chart`'s new actions; `slide.add_chart` takes `radar_filled` and `bubble`,
`category_order`, `gridlines` and `bubble_scale`. A series and a point are found by name
and label as `series_named` and `category_index` find them. The every-definition guard
rises to 11,200 (11,113 estimated, +646), the core unchanged. Golden p15: a maturity gap
chart and an effort-against-impact bubble matrix labelled R1-R6, 10 calls.
- The outline lists each shape once: `slide.shapes` lists a group and then its members, and
`to_outline` (and `ppt_read_slides` `outline`, `outline_blocks`, `find_text`,
`replace_text`) walked the groups as well, so every grouped shape came twice. A group's
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Moved from the README. How to do each of these, with an example the tests run, i
| Speaker notes: read, write, find, address (`<sldId>/notes`) | ✅ |
| Fills and outlines in theme colours; the theme's colours and fonts, resolved and set; which colours do which job, and their tints | ✅ |
| Tables: cell text and formatting, rows, columns, merges, cells by label; rows as drawn, grown to fit their text, and how many fit on the slide (to split a long table) | ✅ |
| Charts: values, categories, series, titles, legend, with cache and embedded workbook together; series and points by label | ✅ |
| Charts: values, categories, series, titles, legend, with cache and embedded workbook together; series and points by label; axis scales, gridlines, colours, markers, combination charts, chart text, point labels; filled radar and bubble charts | ✅ |
| SmartArt: node text, adding and removing nodes | ✅ |
| Shapes, pictures, connectors that stay attached, groups, z-order | ✅ |
| Comments: PowerPoint's threaded comments on a slide or a shape -- read, add, reply, resolve, reopen, edit, delete | ✅ |
Expand Down
25 changes: 25 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,31 @@ Series names and category labels are as strict as table labels (`LabelError`). A
is always a position; to address a numeric category by label use
`chart.category_index(2025)`.

**The look.** Axis scales, gridlines, colours, markers, a column series drawn as a line,
the chart's text and each point's own label, each one undo step; colours as shapes take
them (`tx2`, `accent1 lumMod=60% lumOff=40%`, `1F4E79`, `None` for none):

```python
chart.set_axis_scale("value", minimum=0, maximum=5, major_unit=1) # a 1-5 maturity scale
chart.set_gridlines("value", major=False)
chart.format_series("Current", fill="tx2")
chart.format_point("Current", "Procure", fill="accent2") # highlight one column
chart.set_series_type("Target", "line") # targets over the columns
chart.format_series("Target", line=None, marker="diamond", marker_size=10, marker_fill="accent6")
chart.set_text_style(size=10) # all the chart's text

ranked = slide.add_chart("bar", ["Worst", "Middle", "Best"], [{"name": "Score", "values": [1.8, 2.4, 3.1]}],
left, top, width, height, category_order="reversed") # reads top-down
matrix = slide.add_chart("bubble", effort, [{"name": "Recommendations", "values": impact, "size": priority}],
left, top, width, height, legend=None)
deck.shape(matrix.id).chart.set_point_labels("Recommendations", ["R1", "R2", "R3"], position="right",
cells=True) # as Value From Cells
```

Reversing an axis crosses the other at its maximum, as PowerPoint needs to keep it in place.
PowerPoint cannot combine horizontal bars with a line, so `set_series_type` refuses a bar
chart.

### SmartArt

```python
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ classifiers = [
# edit (the goldens' bytes repeat), and list_documents gone from the shared tools.
# 0.13.0: Toolbox(font_dirs=...) and DocumentFormat.configure; ooxml-common 0.8.0 resolves
# them (office.user_font_dirs, OOXML_FONT_DIRS). 0.14.0: Call.unlocked (render and check
# on a snapshot, the lock let go) and slide ids in their slides.
dependencies = ["lxml>=4.9", "ooxml-common>=0.8.0", "ooxml-edit>=0.14.0", "markdown-it-py>=3.0",
# on a snapshot, the lock let go) and slide ids in their slides. 0.15.0: chart formatting
# (axis scales, colours, markers, combination charts, chart text, point labels, bubble and
# filled radar charts) and edit_chart's actions for it.
dependencies = ["lxml>=4.9", "ooxml-common>=0.8.0", "ooxml-edit>=0.15.0", "markdown-it-py>=3.0",
"mdit-py-plugins>=0.4"]

[project.optional-dependencies]
Expand Down
12 changes: 12 additions & 0 deletions src/pptx_agent/edit/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,18 @@ def category_index(self, label) -> int:
return find_label(label, enumerate(categories), what="category",
where=f"{self.address} (chart)")

def _series_position(self, which, series) -> int:
"""A series by name is found as :meth:`series_named` finds it (strictly)."""
if isinstance(which, str):
return self.series_named(which).index
return super()._series_position(which, series)

def _point(self, root, ser, which) -> int:
"""A point by category label is found as :meth:`category_index` finds it."""
if isinstance(which, str):
return self.category_index(which)
return super()._point(root, ser, which)

def set_category(self, index, label) -> "Chart":
"""Relabel a category -- by position, or by its current label -- in every series'
cache and in the workbook::
Expand Down
19 changes: 13 additions & 6 deletions src/pptx_agent/edit/creating.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,19 +220,25 @@ def build(identifier: int, local: str) -> Element:
def add_chart(self, chart_type: str, categories, series, left: int, top: int, width: int,
height: int, *, title: str | None = None, axis_titles: Mapping | None = None,
legend: str | None = "default", number_format: str | None = None,
name: str | None = None) -> "Shape":
name: str | None = None, category_order: str = "normal",
gridlines: bool = True, bubble_scale: int | None = None) -> "Shape":
"""A new chart from data, in a graphic frame at ``left``/``top`` (EMU), as PowerPoint
inserts one: the chart part and an embedded workbook that holds the same numbers, so
Edit Data opens them (ooxml-edit's :func:`~ooxml_edit.charts.add_chart`).

``chart_type`` is ``column``, ``stacked_column``, ``bar``, ``stacked_bar``, ``line``,
``pie``, ``scatter`` or ``radar``; ``categories`` the category labels (a scatter chart's x
values); ``series`` ``[{"name": ..., "values": [...]}]``, one value per category
(``None`` is a blank); data that cannot be charted raises
``pie``, ``scatter``, ``radar``, ``radar_filled`` or ``bubble``; ``categories`` the
category labels (a scatter or bubble chart's x values); ``series`` ``[{"name": ...,
"values": [...]}]``, one value per category (``None`` is a blank) -- a column chart's
series may add ``"type": "line"`` (target markers over actual columns, with
:meth:`Chart.format_series`), a bubble chart's give ``"size"``; data that cannot be charted raises
:class:`~pptx_agent.ChartDataError` before anything changes. ``title``, ``axis_titles`` (``{"category", "value"}``),
``legend`` (``bottom``, ``right``, ``top``, ``left``, ``top_right``, ``None``, or
``default``: PowerPoint's, at the bottom, a radar chart's at the top) and
``number_format`` (an Excel format code for the values) are optional. It looks as
``number_format`` (an Excel format code for the values) are optional;
``category_order="reversed"`` lists a bar chart's categories top-down (the data and
the workbook keep their order), ``gridlines=False`` draws none, ``bubble_scale`` sizes
the bubbles in percent of PowerPoint's (100). It looks as
PowerPoint's new chart of that type looks, in the deck's theme (measured: accents in
order, 18.62 pt title, 11.97 pt labels, legend at the bottom -- a radar's at the top --,
no fill). Edit it with :attr:`Shape.chart`. One undo step.
Expand All @@ -258,7 +264,8 @@ def build(identifier: int, local: str) -> Element:
categories, series, title=title,
axis_titles=dict(axis_titles or {}), legend=legend,
number_format=number_format, look=POWERPOINT_LOOK,
lang=language))
lang=language, category_order=category_order,
gridlines=gridlines, bubble_scale=bubble_scale))
return authoring.chart_frame(identifier, label, local, left, top, width, height,
made[-1].graphic())

Expand Down
2 changes: 1 addition & 1 deletion src/pptx_agent/tools/SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)).
| Z-order, group, ungroup, duplicate, delete | `ppt_arrange` | |
| Pictures: insert, replace | `ppt_add_picture` | from an image blob; replacing keeps the frame, height or width |
| Tables: new; cells, rows, columns, merges, widths, heights, fill, borders; style and Table Design flags | `ppt_add_table`, `ppt_edit_table` (`set_style`, `set_flags`) | cells by row and column or by their labels; a style by name or id, the deck's own (a template's) or any of PowerPoint's 74 built-in styles; header row, total row, first and last column, banded rows and columns |
| Charts: new from data; read; values, categories, series, titles, axis titles, legend, data labels, gap width | `edit_chart` | column, stacked column, bar, stacked bar, line, pie, scatter, radar (lines, as PowerPoint inserts one; not the filled or marker radar); the embedded workbook changes with the chart |
| Charts: new from data; read; values, categories, series, titles, axis titles, legend, data labels, gap width, axis scales, gridlines, series and point colours and markers, a column series as a line, chart text, point labels | `edit_chart` | column, stacked column, bar, stacked bar, line, pie, scatter, radar (lines), filled radar, bubble, as PowerPoint inserts each; point labels from the workbook as PowerPoint's Value From Cells; the embedded workbook changes with the chart |
| SmartArt: node text, adding and removing nodes | `edit_smartart` | diagrams already in the deck; PowerPoint lays the diagram out again on opening |
| The theme's colours and fonts | `ppt_set_theme` | every theme reference in the deck follows |
| Document properties | `set_properties` | title, author, language, subject |
Expand Down
64 changes: 54 additions & 10 deletions src/pptx_agent/tools/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from typing import Any

from ooxml_edit.charts.actions import STYLE_ACTIONS, apply_style_action, chart_style_json
from ooxml_edit.tools import Result, ToolError, shared

from .common import KIND, box_emu, library_errors, shape as shape_at, slide as slide_at
Expand Down Expand Up @@ -49,6 +50,19 @@ def _category(chart, which: str | None):
return chart.category_index(which)


def _point(chart, which: str) -> int:
"""A point by category label, or position from 0 (a scatter chart's x values are
labels too)."""
labels = [str(c) for c in chart.categories]
if which in labels:
hits = [k for k, label in enumerate(labels) if label == which]
if len(hits) == 1:
return hits[0]
if which.isdigit() and int(which) < len(labels):
return int(which)
return _category(chart, which)


def chart_json(frame, chart) -> dict[str, Any]:
data = {"address": frame.id, "type": chart.chart_type, "title": chart.title,
"categories": list(chart.categories),
Expand All @@ -61,33 +75,49 @@ def chart_json(frame, chart) -> dict[str, Any]:


def _add(call, target, chart_type, categories, data, box, width, values, number_format, text,
position, ref) -> Result:
position, ref, axis=None, scale=None, gridlines=None) -> Result:
"""``action: "add"``: a new chart on the slide ``target``, in ``box``."""
if width is not None:
raise ToolError("invalid_arguments", "width is for documents; on a slide give box",
field="width")
for name, given in (("chart_type", chart_type), ("data", data), ("box", box)):
if given is None:
raise ToolError("invalid_arguments", f"add needs {name}", field=name)
if chart_type == "scatter":
if chart_type in ("scatter", "bubble"):
if values is None or categories is not None:
raise ToolError("invalid_arguments", "a scatter chart takes its x values in values, "
"not categories", field="values")
raise ToolError("invalid_arguments", f"a {chart_type} chart takes its x values in "
"values, not categories", field="values")
labels = list(values)
else:
if categories is None:
raise ToolError("invalid_arguments", "add needs categories", field="categories")
if values is not None:
raise ToolError("invalid_arguments", "values are a scatter chart's x values; give "
"each series' values in data", field="values")
raise ToolError("invalid_arguments", "values are a scatter or bubble chart's x "
"values; give each series' values in data", field="values")
labels = list(categories)
if len(labels) * max(len(data), 1) > 10_000:
raise ToolError("limit", "at most 10,000 chart values", field="data")
host = slide_at(call.document, target, field="target")
x, y, w, h = box_emu(box)
if position not in (None, "none", "right", "left", "top", "bottom"):
raise ToolError("invalid_arguments", f"{position} is a label position; a legend is "
"right, left, top, bottom or none", field="position")
if scale is not None and axis is None:
raise ToolError("invalid_arguments", "add with scale needs axis", field="axis")
legend = None if position == "none" else (position or "default")
frame = host.add_chart(chart_type, labels, [dict(entry) for entry in data], x, y, w, h,
title=text, legend=legend, number_format=number_format)
reverse = (scale or {}).get("reverse") and axis == "category"
rest = {k: v for k, v in (scale or {}).items() if not (reverse and k == "reverse")}
with call.document.batch(): # the chart and its scale: one undo step
frame = host.add_chart(chart_type, labels, [dict(entry) for entry in data], x, y, w, h,
title=text, legend=legend, number_format=number_format,
category_order="reversed" if reverse else "normal",
gridlines=gridlines != "none")
chart = call.document.shape(frame.id).chart
if rest:
apply_style_action(chart, "set_axis_scale", axis=axis, scale=rest)
if gridlines not in (None, "major", "none"):
apply_style_action(chart, "set_gridlines", axis="value" if chart_type not in
("scatter", "bubble") else "y", gridlines=gridlines)
if ref:
call.define_ref(ref, frame.id)
call.touch(host.slide_id)
Expand All @@ -99,6 +129,7 @@ def _read(frame, chart) -> Result:
data = chart_json(frame, chart)
data["legend"] = chart.has_legend
data["axes"] = {axis: chart.axis_title(axis) for axis in chart.axes}
data.update(chart_style_json(chart))
data["workbook"] = chart.workbook_values()
return Result(summary=f"Chart {frame.id}: {len(data['series'])} series, "
f"{len(data['categories'])} categories", data=data)
Expand All @@ -108,13 +139,26 @@ def _read(frame, chart) -> Result:
@library_errors
def edit_chart(call, doc, target, action, chart_type=None, categories=None, data=None, box=None,
width=None, number_format=None, series=None, category=None, values=None,
value=None, text=None, axis=None, position=None, ref=None):
value=None, text=None, axis=None, position=None, ref=None, scale=None,
gridlines=None, style=None, part=None, labels=None, cells=None, secondary=None):
if action == "add":
return _add(call, target, chart_type, categories, data, box, width, values,
number_format, text, position, ref)
number_format, text, position, ref, axis, scale, gridlines)
frame, chart = _chart(call.document, target)
if action == "read":
return _read(frame, chart)
if action in STYLE_ACTIONS:
chosen = None if series is None else _series(chart, series).index
point = None if category is None or chosen is None else _point(chart, category)
apply_style_action(chart, action, series=chosen, category=point, axis=axis,
scale=scale, gridlines=gridlines, style=style, part=part,
labels=labels, cells=cells, position=position,
chart_type=chart_type, secondary=secondary)
call.touch(frame._slide.slide_id)
fresh = call.document.shape(frame.id).chart
data = chart_json(frame, fresh)
data.update(chart_style_json(fresh))
return Result(summary=f"{action} on chart {frame.id}", changed=[frame.id], data=data)

def need(name, given):
if given is None:
Expand Down
13 changes: 13 additions & 0 deletions tests/golden/grading/TASK_p15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Task: chart the maturity gap and the recommendations

`input/maturity-assessment.pptx` is a four-slide maturity assessment of warehouse operations. The panel's scores are in `input/maturity-scores.csv` (one row per capability: the current score and the target, on a scale from 1 to 5), and its recommendations in `input/recommendations.csv` (an id, a name, and the effort, impact and priority each was rated).

Add two slides after "Approach" ("Next steps" moves to slide 6):

- **Slide 4, the maturity gap:** the current scores as columns in the deck's second text colour (`tx2`), with the lowest-scoring capability highlighted in an accent colour; the targets as diamond markers on the same axes, with no line between them. The value axis runs from 0 to 5 in steps of 1, with no gridlines; all the chart's text is 10 pt. The capabilities in the CSV's order, the CSV's values exactly, and Edit Data agrees with what the chart draws.
- **Slide 5, effort against impact:** a bubble chart with each recommendation at its effort (x) and impact (y), sized by its priority, and labelled with its id (R1 to R6) beside the bubble. Both axes from 0 to 5, titled "Effort" and "Impact".
- Each slide has **a one-line takeaway title** (a finding, not a label).
- The charts fill the content area below their titles; nothing overlaps or leaves the slide.
- The other slides do not change.

Save the result as `work/maturity-assessment.pptx`.
Loading
Loading