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
12 changes: 6 additions & 6 deletions docs/api/enum/XlChartType.rst
Original file line number Diff line number Diff line change
Expand Up @@ -236,19 +236,19 @@ WATERFALL
Waterfall (ChartEx). Office 2016+. Write + round-trip supported.

TREEMAP
Treemap (ChartEx). Office 2016+. Round-trip preservation only.
Treemap (ChartEx). Office 2016+. Write + round-trip supported.

SUNBURST
Sunburst (ChartEx). Office 2016+. Round-trip preservation only.
Sunburst (ChartEx). Office 2016+. Write + round-trip supported.

FUNNEL
Funnel (ChartEx). Office 2016+. Round-trip preservation only.
Funnel (ChartEx). Office 2016+. Write + round-trip supported.

BOX_WHISKER
Box & Whisker (ChartEx). Office 2016+. Round-trip preservation only.
Box & Whisker (ChartEx). Office 2016+. Write + round-trip supported.

HISTOGRAM
Histogram (ChartEx). Office 2016+. Round-trip preservation only.
Histogram (ChartEx). Office 2016+. Write + round-trip supported.

PARETO
Pareto (ChartEx). Office 2016+. Round-trip preservation only.
Pareto (ChartEx). Office 2016+. Write + round-trip supported.
53 changes: 38 additions & 15 deletions docs/user/charts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -274,16 +274,11 @@ namespace (``cx:``, the *chart extensions* or "chartEx" part) rather than the
classic ``c:`` chart tree. |pp| supports this family with two distinct
capability levels:

================== ============================ =========================
Capability Chart types What you can do
================== ============================ =========================
**Write** ``WATERFALL`` Author a brand-new chart
**Round-trip** ``WATERFALL``, ``TREEMAP``, Open a deck that already
only ``SUNBURST``, ``FUNNEL``, contains the chart, edit
``BOX_WHISKER``, unrelated slides, and
``HISTOGRAM``, ``PARETO`` save without corrupting
the chartEx part
================== ============================ =========================
As of Phase C (issue #14) **all** ChartEx types are write-capable:
``WATERFALL``, ``TREEMAP``, ``SUNBURST``, ``FUNNEL``, ``BOX_WHISKER``,
``HISTOGRAM``, and ``PARETO`` can each be authored with ``add_chart`` and
also round-trip (open a deck that already contains the chart, edit unrelated
slides, save without corrupting the chartEx part).

Authoring a waterfall chart uses the dedicated
:class:`~pptx.chart.data.WaterfallChartData` container::
Expand All @@ -303,11 +298,39 @@ The returned |GraphicFrame| reports ``graphic_frame.has_chartex == True`` and
its :attr:`~pptx.shapes.graphfrm.GraphicFrame.chartex` property returns a
ChartEx proxy. (Classic charts continue to use ``.has_chart`` / ``.chart``.)

The remaining ``cx:`` types currently have **round-trip preservation only** —
``add_chart`` raises ``NotImplementedError`` for them, but a deck authored in
PowerPoint that already contains a treemap, sunburst, etc. will read, modify,
and save without damaging the existing chart. Writer support for those types
is tracked as a follow-up to issue #14.
The other ChartEx types use purpose-built data containers from
``pptx.chart.data``:

- ``TreemapChartData`` / ``SunburstChartData`` — hierarchical; call
``add_level(labels)`` outermost-first, then ``add_series(name, values)``
for the leaf values.
- ``FunnelChartData`` / ``BoxWhiskerChartData`` — ``categories`` plus
``add_series(name, values)``.
- ``HistogramChartData`` / ``ParetoChartData`` — raw values with optional
binning: ``add_series(name, values, bin_count=N)`` (or ``bin_size=...``).

For example, a treemap::

from pptx.chart.data import TreemapChartData

chart_data = TreemapChartData()
chart_data.add_level(['Tech', 'Tech', 'Retail', 'Retail'])
chart_data.add_level(['Phones', 'Laptops', 'Apparel', 'Food'])
chart_data.add_series('Revenue', (50, 30, 20, 15))

slide.shapes.add_chart(
XL_CHART_TYPE.TREEMAP, x, y, cx, cy, chart_data
)

Updating the data of an existing ChartEx chart (any type) uses
:meth:`~pptx.chart.chartex.ChartEx.replace_data`, parallel to the classic
``Chart.replace_data``::

graphic_frame.chartex.replace_data(new_chart_data)

``replace_data`` rewrites the chart data and embedded workbook in place — the
chartEx part and its slide relationship are unchanged — and raises
``ValueError`` if the new data's chart type doesn't match the existing chart.

The full set of ``cx:`` enum members is documented under
:ref:`XlChartType`.
Expand Down
45 changes: 45 additions & 0 deletions features/cht-chartex-phasec.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Feature: ChartEx Phase-C writers and replace_data
In order to author every Office-2016 modern chart type
As a developer using python-pptx
I need each ChartEx type to write, round-trip, and support replace_data


Scenario Outline: Each ChartEx type writes and round-trips
Given a blank slide
When I add a ChartEx <member-name> chart
Then the slide has a ChartEx graphic frame
And the saved package contains a ChartEx part
And the ChartEx round-trips preserving its part

Examples: ChartEx writable types
| member-name |
| WATERFALL |
| TREEMAP |
| SUNBURST |
| FUNNEL |
| BOX_WHISKER |
| HISTOGRAM |
| PARETO |


Scenario Outline: replace_data updates each ChartEx type and round-trips
Given a blank slide
When I add a ChartEx <member-name> chart
And I replace the ChartEx <member-name> data with a smaller dataset
Then the reopened ChartEx reflects the replaced data
And the ChartEx round-trips preserving its part

Examples: replace_data types
| member-name |
| WATERFALL |
| TREEMAP |
| SUNBURST |
| FUNNEL |
| HISTOGRAM |
| PARETO |


Scenario: replace_data rejects a chart-type mismatch
Given a blank slide
When I attempt to replace a FUNNEL ChartEx with HISTOGRAM data
Then a chart-type mismatch error is raised
13 changes: 7 additions & 6 deletions features/cht-chartex-types.feature
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
Feature: ChartEx chart type members
In order to use the ChartEx chart type enumeration safely
As a developer using python-pptx
I need deferred members to fail explicitly and modern members to exist in a private range
I need every modern member to exist in a private range and be writable


Scenario Outline: Writer-deferred ChartEx types fail through add_chart
Scenario Outline: Every ChartEx type is writable via add_chart (Phase C)
Given a blank slide
And ChartEx waterfall data case q4-total
When I attempt to add deferred ChartEx type <member-name>
Then adding deferred ChartEx type <member-name> raises NotImplementedError
When I add a ChartEx <member-name> chart
Then the slide has a ChartEx graphic frame
And the saved package contains a ChartEx part

Examples: writer-deferred ChartEx members
Examples: ChartEx writable members
| member-name |
| WATERFALL |
| TREEMAP |
| SUNBURST |
| FUNNEL |
Expand Down
159 changes: 159 additions & 0 deletions features/steps/chartex_phasec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Gherkin step implementations for ChartEx Phase-C features (issue #14):
writers for Treemap/Sunburst/Funnel/BoxWhisker/Histogram/Pareto + replace_data.
"""

from __future__ import annotations

import io
import zipfile

from behave import then, when

from pptx import Presentation
from pptx.chart.data import (
BoxWhiskerChartData,
FunnelChartData,
HistogramChartData,
ParetoChartData,
SunburstChartData,
TreemapChartData,
WaterfallChartData,
)
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches


def _data_for(member_name):
m = member_name.strip()
if m == "WATERFALL":
cd = WaterfallChartData()
cd.categories = ["Q1", "Q2", "Total"]
cd.add_series("R", [10, 20, 30], subtotals=[2])
return XL_CHART_TYPE.WATERFALL, cd
if m in ("TREEMAP", "SUNBURST"):
cls = TreemapChartData if m == "TREEMAP" else SunburstChartData
cd = cls()
cd.add_level(["A", "A", "B", "B"])
cd.add_level(["a1", "a2", "b1", "b2"])
cd.add_series("Rev", [40, 30, 20, 10])
return getattr(XL_CHART_TYPE, m), cd
if m in ("FUNNEL", "BOX_WHISKER"):
cls = FunnelChartData if m == "FUNNEL" else BoxWhiskerChartData
cd = cls()
cd.categories = ["Leads", "Qualified", "Won"]
cd.add_series("Pipe", [100, 60, 25])
return getattr(XL_CHART_TYPE, m), cd
if m == "HISTOGRAM":
cd = HistogramChartData()
cd.add_series("Scores", [55, 62, 71, 73, 88, 91, 64, 78], bin_count=4)
return XL_CHART_TYPE.HISTOGRAM, cd
if m == "PARETO":
# PowerPoint Pareto is categorical (ground truth, issue #14).
cd = ParetoChartData()
cd.categories = ["Defect A", "Defect B", "Defect C", "Defect D"]
cd.add_series("Count", [45, 30, 15, 10])
return XL_CHART_TYPE.PARETO, cd
raise KeyError(m)


def _cx_parts(blob):
z = zipfile.ZipFile(io.BytesIO(blob))
return [n for n in z.namelist() if "chartEx" in n and n.endswith(".xml")]


# when ====================================================


@when("I add a ChartEx {member_name} chart")
def when_i_add_a_chartex_member_chart(context, member_name):
ct, cd = _data_for(member_name)
context.cx_member = member_name.strip()
context.cx_data = cd
context.cx_frame = context.slide.shapes.add_chart(
ct, Inches(1), Inches(1), Inches(6), Inches(4), cd
)


@when("I replace the ChartEx {member_name} data with a smaller dataset")
def when_i_replace_chartex_data(context, member_name):
_, new_cd = _data_for(member_name)
# shrink it so the change is observable
if hasattr(new_cd, "levels"):
nd = type(new_cd)()
nd.add_level(["Z", "Z"])
nd.add_level(["z1", "z2"])
nd.add_series("New", [7, 3])
elif hasattr(new_cd, "categories"):
nd = type(new_cd)()
nd.categories = ["Only"]
nd.add_series("New", [42])
else:
nd = type(new_cd)()
nd.add_series("New", [1, 2, 3, 4], bin_count=2)
context.cx_replacement = nd
context.cx_frame.chartex.replace_data(nd)


@when("I attempt to replace a {a_type} ChartEx with {b_type} data")
def when_attempt_mismatch_replace(context, a_type, b_type):
ct, cd = _data_for(a_type)
frame = context.slide.shapes.add_chart(ct, Inches(1), Inches(1), Inches(6), Inches(4), cd)
_, bad = _data_for(b_type)
context.cx_replace_error = None
try:
frame.chartex.replace_data(bad)
except ValueError as e:
context.cx_replace_error = e


# then ====================================================


@then("the slide has a ChartEx graphic frame")
def then_slide_has_a_chartex_frame(context):
frames = [s for s in context.slide.shapes if getattr(s, "has_chartex", False)]
assert len(frames) >= 1, "no ChartEx graphic frame on slide"


@then("the saved package contains a ChartEx part")
def then_saved_package_contains_chartex_part(context):
buf = io.BytesIO()
context.prs.save(buf)
assert _cx_parts(buf.getvalue()), "no chartEx part in saved package"


@then("the ChartEx round-trips preserving its part")
def then_chartex_round_trips(context):
buf = io.BytesIO()
context.prs.save(buf)
before = sorted(_cx_parts(buf.getvalue()))
prs2 = Presentation(io.BytesIO(buf.getvalue()))
prs2.slides.add_slide(prs2.slide_layouts[0]) # unrelated edit (layout 0 always exists)
buf2 = io.BytesIO()
prs2.save(buf2)
after = sorted(_cx_parts(buf2.getvalue()))
assert before and before == after, f"{before!r} != {after!r}"
rt = [s for s in prs2.slides[0].shapes if getattr(s, "has_chartex", False)]
assert len(rt) == 1


@then("the reopened ChartEx reflects the replaced data")
def then_reopened_reflects_replaced(context):
buf = io.BytesIO()
context.prs.save(buf)
prs2 = Presentation(io.BytesIO(buf.getvalue()))
z = zipfile.ZipFile(io.BytesIO(buf.getvalue()))
name = next(
n for n in z.namelist() if "chartEx" in n and n.endswith(".xml") and "_rels" not in n
)
xml = z.read(name).decode()
nd = context.cx_replacement
token = "New"
assert token in xml, "replaced series name not found after reopen"
assert prs2 is not None


@then("a chart-type mismatch error is raised")
def then_mismatch_error_raised(context):
assert context.cx_replace_error is not None
assert "cannot change chart type" in str(context.cx_replace_error)
Loading
Loading