diff --git a/docs/api/enum/XlChartType.rst b/docs/api/enum/XlChartType.rst index 0470bf008..5326ad6bb 100644 --- a/docs/api/enum/XlChartType.rst +++ b/docs/api/enum/XlChartType.rst @@ -231,3 +231,24 @@ XY_SCATTER_SMOOTH XY_SCATTER_SMOOTH_NO_MARKERS Scatter with Smoothed Lines and No Data Markers. + +WATERFALL + Waterfall (ChartEx). Office 2016+. Write + round-trip supported. + +TREEMAP + Treemap (ChartEx). Office 2016+. Round-trip preservation only. + +SUNBURST + Sunburst (ChartEx). Office 2016+. Round-trip preservation only. + +FUNNEL + Funnel (ChartEx). Office 2016+. Round-trip preservation only. + +BOX_WHISKER + Box & Whisker (ChartEx). Office 2016+. Round-trip preservation only. + +HISTOGRAM + Histogram (ChartEx). Office 2016+. Round-trip preservation only. + +PARETO + Pareto (ChartEx). Office 2016+. Round-trip preservation only. diff --git a/docs/user/charts.rst b/docs/user/charts.rst index dfe27fd2f..e5d11fc01 100644 --- a/docs/user/charts.rst +++ b/docs/user/charts.rst @@ -265,6 +265,54 @@ presentation with |pp|. There are more details in the API documentation for charts here: :ref:`chart-api` +ChartEx — modern Office 2016 charts (waterfall, treemap, sunburst, ...) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Office 2016 introduced a new chart family — Waterfall, Treemap, Sunburst, +Funnel, Box & Whisker, Histogram, and Pareto — that lives in a separate XML +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 +================== ============================ ========================= + +Authoring a waterfall chart uses the dedicated +:class:`~pptx.chart.data.WaterfallChartData` container:: + + from pptx.chart.data import WaterfallChartData + from pptx.enum.chart import XL_CHART_TYPE + + chart_data = WaterfallChartData() + chart_data.categories = ['Q1', 'Q2', 'Q3', 'Q4', 'Total'] + chart_data.add_series('Revenue', (100, 50, -30, 80, 200), subtotals=[4]) + + graphic_frame = slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, x, y, cx, cy, chart_data + ) + +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 full set of ``cx:`` enum members is documented under +:ref:`XlChartType`. + + About colors ~~~~~~~~~~~~ diff --git a/features/cht-chartex-roundtrip.feature b/features/cht-chartex-roundtrip.feature new file mode 100644 index 000000000..cc78de3e5 --- /dev/null +++ b/features/cht-chartex-roundtrip.feature @@ -0,0 +1,47 @@ +Feature: ChartEx round-trip behavior + In order to preserve modern chart parts in saved presentations + As a developer using python-pptx + I need ChartEx content and classic-chart content to round-trip correctly + + + Scenario Outline: A waterfall ChartEx part survives save and reopen + Given a blank slide + And ChartEx waterfall data case q4-total + When I add the ChartEx waterfall via + And I round-trip the presentation for ChartEx inspection + Then the active ChartEx frame exposes ChartEx but not a classic chart + And the active ChartEx part content type is the ChartEx content type + And the active ChartEx partname contains chartEx + And the active ChartEx series values are 100.0, 50.0, -30.0, 80.0, 200.0 + + Examples: round-trip entry points + | add-path | + | add_chart | + | add_chartex | + + + Scenario: Classic and ChartEx chart frames coexist on one slide across round-trip + Given a blank slide + And ChartEx waterfall data case regional-rollup + When I add a classic chart beside a ChartEx waterfall + Then the slide has one classic chart frame and one ChartEx frame + And the classic chart frame still exposes only a classic chart + And the ChartEx frame still exposes only a ChartEx chart + When I round-trip the presentation for ChartEx inspection + Then the slide has one classic chart frame and one ChartEx frame + And the classic chart frame still exposes only a classic chart + And the ChartEx frame still exposes only a ChartEx chart + And the active ChartEx series values are 30.0, -10.0, 25.0, 15.0, 60.0 + + + Scenario: A blank presentation exposes no ChartEx frames + Given a blank slide + Then the slide has no ChartEx graphic frames + + + Scenario: A blank presentation saves without any ChartEx package parts + Given a blank slide + When I round-trip the presentation for ChartEx inspection + Then the slide has no ChartEx graphic frames + And the saved package contains no ChartEx partnames + And the saved package contains no ChartEx content type declaration diff --git a/features/cht-chartex-types.feature b/features/cht-chartex-types.feature new file mode 100644 index 000000000..72f0b7b38 --- /dev/null +++ b/features/cht-chartex-types.feature @@ -0,0 +1,34 @@ +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 + + + Scenario Outline: Writer-deferred ChartEx types fail through add_chart + Given a blank slide + And ChartEx waterfall data case q4-total + When I attempt to add deferred ChartEx type + Then adding deferred ChartEx type raises NotImplementedError + + Examples: writer-deferred ChartEx members + | member-name | + | TREEMAP | + | SUNBURST | + | FUNNEL | + | BOX_WHISKER | + | HISTOGRAM | + | PARETO | + + + Scenario Outline: ChartEx enum members exist in the private high range + Then XL_CHART_TYPE. exists with value + + Examples: ChartEx enum members + | member-name | value | + | WATERFALL | 1001 | + | TREEMAP | 1002 | + | SUNBURST | 1003 | + | FUNNEL | 1004 | + | BOX_WHISKER | 1005 | + | HISTOGRAM | 1006 | + | PARETO | 1007 | diff --git a/features/cht-chartex-waterfall.feature b/features/cht-chartex-waterfall.feature new file mode 100644 index 000000000..1a6791c6f --- /dev/null +++ b/features/cht-chartex-waterfall.feature @@ -0,0 +1,54 @@ +Feature: ChartEx waterfall charts + In order to add Office 2016 waterfall charts to a slide + As a developer using python-pptx + I need the ChartEx writer path to create modern chart graphic frames + + + Scenario Outline: Add a waterfall chart through either public entry point + Given a blank slide + And ChartEx waterfall data case q4-total + When I add the ChartEx waterfall via + Then the active ChartEx frame exposes ChartEx but not a classic chart + And the active ChartEx chart type is waterfall + And the active ChartEx series is named Revenue + And the active ChartEx series values are 100.0, 50.0, -30.0, 80.0, 200.0 + + Examples: public waterfall entry points + | add-path | + | add_chart | + | add_chartex | + + + Scenario Outline: Waterfall category labels are preserved on creation + Given a blank slide + And ChartEx waterfall data case + When I add the ChartEx waterfall via + Then the active ChartEx category labels are + + Examples: category label sets + | data-case | add-path | category-labels | + | q4-total | add_chart | Q1, Q2, Q3, Q4, Total | + | q4-total | add_chartex | Q1, Q2, Q3, Q4, Total | + | cash-bridge | add_chart | Start, Sales, Returns, Ops, Tax, End | + | cash-bridge | add_chartex | Start, Sales, Returns, Ops, Tax, End | + | regional-rollup | add_chart | East, West, Midwest, Online, Total | + | regional-rollup | add_chartex | East, West, Midwest, Online, Total | + + + Scenario Outline: Waterfall subtotal markers survive round-trip + Given a blank slide + And ChartEx waterfall data case + When I add the ChartEx waterfall via + And I round-trip the presentation for ChartEx inspection + Then the active ChartEx frame exposes ChartEx but not a classic chart + And the active ChartEx subtotal indices are + And the active ChartEx category labels are + + Examples: subtotal preservation cases + | data-case | add-path | subtotal-indices | category-labels | + | q4-total | add_chart | 4 | Q1, Q2, Q3, Q4, Total | + | q4-total | add_chartex | 4 | Q1, Q2, Q3, Q4, Total | + | cash-bridge | add_chart | 0, 5 | Start, Sales, Returns, Ops, Tax, End | + | cash-bridge | add_chartex | 0, 5 | Start, Sales, Returns, Ops, Tax, End | + | regional-rollup | add_chart | 4 | East, West, Midwest, Online, Total | + | regional-rollup | add_chartex | 4 | East, West, Midwest, Online, Total | diff --git a/features/steps/chartex.py b/features/steps/chartex.py new file mode 100644 index 000000000..7b85ecc8d --- /dev/null +++ b/features/steps/chartex.py @@ -0,0 +1,272 @@ +"""Gherkin step implementations for ChartEx features.""" + +from __future__ import annotations + +import os +import zipfile + +from behave import given, then, when +from helpers import saved_pptx_path + +from pptx import Presentation +from pptx.chart.data import CategoryChartData, WaterfallChartData +from pptx.enum.chart import XL_CHART_TYPE +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.oxml.ns import qn +from pptx.util import Inches + +# given =================================================== + + +@given("ChartEx waterfall data case {data_case}") +def given_chartex_waterfall_data_case_data_case(context, data_case): + chart_data = WaterfallChartData() + series_name, categories, values, subtotals = { + "q4-total": ( + "Revenue", + ["Q1", "Q2", "Q3", "Q4", "Total"], + [100, 50, -30, 80, 200], + [4], + ), + "cash-bridge": ( + "Cash Flow", + ["Start", "Sales", "Returns", "Ops", "Tax", "End"], + [500, 120, -20, -75, -25, 500], + [0, 5], + ), + "regional-rollup": ( + "Margin", + ["East", "West", "Midwest", "Online", "Total"], + [30, -10, 25, 15, 60], + [4], + ), + }[data_case] + chart_data.categories = categories + chart_data.add_series(series_name, values, subtotals=subtotals) + context.chart_data = chart_data + + +# when ==================================================== + + +@when("I add the ChartEx waterfall via {add_path}") +def when_I_add_the_ChartEx_waterfall_via_add_path(context, add_path): + shapes = context.slide.shapes + if add_path == "add_chart": + context.graphic_frame = shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + context.chart_data, + ) + else: + context.graphic_frame = shapes.add_chartex( + context.chart_data, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + ) + context.graphic_frame_idx = len(shapes) - 1 + + +@when("I round-trip the presentation for ChartEx inspection") +def when_I_round_trip_the_presentation_for_ChartEx_inspection(context): + if os.path.isfile(saved_pptx_path): + os.remove(saved_pptx_path) + context.prs.save(saved_pptx_path) + context.saved_package_names = zipfile.ZipFile(saved_pptx_path).namelist() + context.saved_content_types_xml = ( + zipfile.ZipFile(saved_pptx_path).read("[Content_Types].xml").decode("utf-8") + ) + context.prs = Presentation(saved_pptx_path) + context.slide = context.prs.slides[0] + if hasattr(context, "graphic_frame_idx"): + context.graphic_frame = context.slide.shapes[context.graphic_frame_idx] + if hasattr(context, "classic_graphic_frame_idx"): + context.classic_graphic_frame = context.slide.shapes[context.classic_graphic_frame_idx] + if hasattr(context, "chartex_graphic_frame_idx"): + context.chartex_graphic_frame = context.slide.shapes[context.chartex_graphic_frame_idx] + + +@when("I add a classic chart beside a ChartEx waterfall") +def when_I_add_a_classic_chart_beside_a_ChartEx_waterfall(context): + chart_data = CategoryChartData() + chart_data.categories = ["North", "South", "West"] + chart_data.add_series("Orders", (4, 7, 3)) + + context.classic_graphic_frame = context.slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(0.5), + Inches(0.5), + Inches(4.5), + Inches(3.0), + chart_data, + ) + context.classic_graphic_frame_idx = len(context.slide.shapes) - 1 + + context.chartex_graphic_frame = context.slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(5.2), + Inches(0.5), + Inches(4.0), + Inches(3.0), + context.chart_data, + ) + context.chartex_graphic_frame_idx = len(context.slide.shapes) - 1 + context.graphic_frame = context.chartex_graphic_frame + + +@when("I attempt to add deferred ChartEx type {member_name}") +def when_I_attempt_to_add_deferred_ChartEx_type_member_name(context, member_name): + try: + context.chartex_error = None + context.slide.shapes.add_chart( + getattr(XL_CHART_TYPE, member_name), + Inches(1), + Inches(1), + Inches(6), + Inches(4), + context.chart_data, + ) + except Exception as err: + context.chartex_error = err + + +# then ==================================================== + + +@then("the active ChartEx frame exposes ChartEx but not a classic chart") +def then_the_active_ChartEx_frame_exposes_ChartEx_but_not_a_classic_chart(context): + assert context.graphic_frame.has_chartex is True + assert context.graphic_frame.has_chart is False + assert context.graphic_frame.chartex is not None + + +@then("the active ChartEx chart type is waterfall") +def then_the_active_ChartEx_chart_type_is_waterfall(context): + assert context.graphic_frame.chartex.chart_type == "waterfall" + + +@then("the active ChartEx series is named {expected_name}") +def then_the_active_ChartEx_series_is_named_expected_name(context, expected_name): + series = context.graphic_frame.chartex.series + assert len(series) == 1 + assert series[0].name == expected_name + + +@then("the active ChartEx series values are {expected_values}") +def then_the_active_ChartEx_series_values_are_expected_values(context, expected_values): + series = context.graphic_frame.chartex.series + actual_values = series[0].values + assert actual_values == _float_values(expected_values) + + +@then("the active ChartEx category labels are {expected_labels}") +def then_the_active_ChartEx_category_labels_are_expected_labels(context, expected_labels): + actual_labels = _chart_labels(context.graphic_frame.chartex) + assert actual_labels == _csv_values(expected_labels) + + +@then("the active ChartEx subtotal indices are {expected_indices}") +def then_the_active_ChartEx_subtotal_indices_are_expected_indices(context, expected_indices): + actual_indices = _subtotal_indices(context.graphic_frame.chartex) + assert actual_indices == _int_values(expected_indices) + + +@then("the active ChartEx part content type is the ChartEx content type") +def then_the_active_ChartEx_part_content_type_is_the_ChartEx_content_type(context): + assert context.graphic_frame.chartex_part.content_type == CT.OFC_CHART_EX + + +@then("the active ChartEx partname contains chartEx") +def then_the_active_ChartEx_partname_contains_chartEx(context): + assert "chartEx" in str(context.graphic_frame.chartex_part.partname) + + +@then("the slide has one classic chart frame and one ChartEx frame") +def then_the_slide_has_one_classic_chart_frame_and_one_ChartEx_frame(context): + classic_frames = [shape for shape in context.slide.shapes if getattr(shape, "has_chart", False)] + chartex_frames = [ + shape for shape in context.slide.shapes if getattr(shape, "has_chartex", False) + ] + assert len(classic_frames) == 1 + assert len(chartex_frames) == 1 + + +@then("the classic chart frame still exposes only a classic chart") +def then_the_classic_chart_frame_still_exposes_only_a_classic_chart(context): + assert context.classic_graphic_frame.has_chart is True + assert context.classic_graphic_frame.has_chartex is False + assert context.classic_graphic_frame.chart is not None + + +@then("the ChartEx frame still exposes only a ChartEx chart") +def then_the_ChartEx_frame_still_exposes_only_a_ChartEx_chart(context): + assert context.chartex_graphic_frame.has_chartex is True + assert context.chartex_graphic_frame.has_chart is False + assert context.chartex_graphic_frame.chartex is not None + + +@then("the slide has no ChartEx graphic frames") +def then_the_slide_has_no_ChartEx_graphic_frames(context): + chartex_frames = [ + shape for shape in context.slide.shapes if getattr(shape, "has_chartex", False) + ] + assert chartex_frames == [] + + +@then("the saved package contains no ChartEx partnames") +def then_the_saved_package_contains_no_ChartEx_partnames(context): + assert not any("chartEx" in name for name in context.saved_package_names) + + +@then("the saved package contains no ChartEx content type declaration") +def then_the_saved_package_contains_no_ChartEx_content_type_declaration(context): + assert CT.OFC_CHART_EX not in context.saved_content_types_xml + + +@then("adding deferred ChartEx type {member_name} raises NotImplementedError") +def then_adding_deferred_ChartEx_type_member_name_raises_NotImplementedError(context, member_name): + assert isinstance(context.chartex_error, NotImplementedError) + assert member_name in str(context.chartex_error) + + +@then("XL_CHART_TYPE.{member_name} exists with value {expected_value}") +def then_XL_CHART_TYPE_member_name_exists_with_value_expected_value( + context, member_name, expected_value +): + assert hasattr(XL_CHART_TYPE, member_name) + member = getattr(XL_CHART_TYPE, member_name) + actual_value = member.value + assert actual_value == int(expected_value) + assert actual_value >= 1000 + + +def _chart_labels(chartex): + cat_dim = chartex._element.chartData.data_lst[0].strDim_lst[0] + levels = cat_dim.lvl_lst + assert len(levels) == 1 + return [pt.text for pt in levels[0]] + + +def _csv_values(csv_text): + return [item.strip() for item in csv_text.split(",")] + + +def _float_values(csv_text): + return [float(item.strip()) for item in csv_text.split(",")] + + +def _int_values(csv_text): + if csv_text == "none": + return [] + return [int(item.strip()) for item in csv_text.split(",")] + + +def _subtotal_indices(chartex): + series = chartex._element.chart.plotArea.plotAreaRegion.series_lst[0] + subtotal_elems = series.findall(f".//{qn('cx:subtotals')}/{qn('cx:idx')}") + return [int(elem.get("val")) for elem in subtotal_elems] diff --git a/spec/ISO-IEC-29500-4/xsd/dml-chartex.xsd b/spec/ISO-IEC-29500-4/xsd/dml-chartex.xsd new file mode 100644 index 000000000..5136045d0 --- /dev/null +++ b/spec/ISO-IEC-29500-4/xsd/dml-chartex.xsd @@ -0,0 +1,840 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pptx/__init__.py b/src/pptx/__init__.py index df16aaadc..9936abb85 100644 --- a/src/pptx/__init__.py +++ b/src/pptx/__init__.py @@ -10,6 +10,7 @@ from pptx.opc.constants import CONTENT_TYPE as CT from pptx.opc.package import PartFactory from pptx.parts.chart import ChartPart +from pptx.parts.chartex import ChartColorStylePart, ChartExPart, ChartStylePart from pptx.parts.comments import ( AuthorsPart, CommentAuthorsPart, @@ -64,6 +65,9 @@ CT.PML_SLIDE_LAYOUT: SlideLayoutPart, CT.PML_SLIDE_MASTER: SlideMasterPart, CT.DML_CHART: ChartPart, + CT.OFC_CHART_EX: ChartExPart, + CT.OFC_CHART_STYLE: ChartStylePart, + CT.OFC_CHART_COLORS: ChartColorStylePart, CT.BMP: ImagePart, CT.GIF: ImagePart, CT.JPEG: ImagePart, @@ -89,7 +93,10 @@ PartFactory.part_type_for.update(content_type_to_part_class_map) del ( + ChartColorStylePart, + ChartExPart, ChartPart, + ChartStylePart, AuthorsPart, CommentAuthorsPart, CommentsPart, diff --git a/src/pptx/chart/chartex.py b/src/pptx/chart/chartex.py new file mode 100644 index 000000000..b0b67597a --- /dev/null +++ b/src/pptx/chart/chartex.py @@ -0,0 +1,357 @@ +"""Chart Extension objects and related items.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pptx.oxml.ns import qn +from pptx.oxml.xmlchemy import OxmlElement +from pptx.shared import ParentedElementProxy, PartElementProxy +from pptx.util import lazyproperty + +if TYPE_CHECKING: + from pptx.chart.data import WaterfallChartData + from pptx.oxml.chart.chartex import CT_Axis, CT_ChartSpace, CT_Series + from pptx.parts.chartex import ChartExPart + + +class ChartEx(PartElementProxy): + """Chart extension object. + + Corresponds to the ```` element that is the root of a chart extension part. + """ + + _chartspace: CT_ChartSpace + + def __init__(self, chartSpace: CT_ChartSpace, chart_part: ChartExPart): + super().__init__(chartSpace, chart_part) + self._chartspace = chartSpace + + @property + def chart_title(self) -> str | None: + """The title of this chart as a string, or None if there is no title. + + Assigning a string value sets the title to that value. Assigning None causes + any title to be deleted. + """ + title = self._chart.title + if title is None: + return None + + title_text = title.find( + ".//cx:txData/cx:v", + namespaces={"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex"}, + ) + if title_text is None: + return None + + return title_text.text + + @property + def chart_type(self) -> str | None: + """The chart type of this chart extension as a string. + + Possible values include: + * waterfall + * sunburst + * treemap + * funnel + * boxWhisker + * clusteredColumn + * paretoLine + * regionMap + + Returns None if the chart type cannot be determined. + """ + plotAreaRegion = self._chart.plotArea.plotAreaRegion + series = plotAreaRegion.find( + ".//cx:series", + namespaces={"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex"}, + ) + if series is None: + return None + + return series.get("layoutId") + + @property + def has_legend(self) -> bool: + """Read/write boolean, |True| if the chart has a legend. + + Assigning |True| causes a legend to be added if not already present. + Assigning |False| removes any existing legend definition. + """ + return self._chart.legend is not None + + @has_legend.setter + def has_legend(self, value: bool): + if bool(value) is False: + self._chart._remove_legend() + else: + if self._chart.legend is None: + self._chart._add_legend() + + @property + def legend(self) -> Legend | None: + """ + A |Legend| object providing access to the properties of the legend + for this chart, or |None| if no legend is defined. + """ + legend_elm = self._chart.legend + if legend_elm is None: + return None + return Legend(legend_elm, self) + + @lazyproperty + def series(self) -> list[Series]: + """A sequence of |Series| objects representing the series in this chart.""" + series_elements = self._chart.plotArea.plotAreaRegion.findall( + ".//cx:series", + namespaces={"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex"}, + ) + return [Series(series, self) for series in series_elements] + + @property + def axes(self) -> list[Axis]: + """A sequence of |Axis| objects representing the axes in this chart.""" + axis_elements = self._chart.plotArea.findall( + ".//cx:axis", + namespaces={"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex"}, + ) + return [Axis(axis, self) for axis in axis_elements] + + def replace_data(self, chart_data: WaterfallChartData): + """Replace the data for this chart with *chart_data*. + + *chart_data* is a |WaterfallChartData| instance populated with the categories, + series values, and subtotal indices for the new chart data. + """ + chartData = self._chartspace.chartData + + # --- rebuild the element --- + for old_data in list(chartData.data_lst): + chartData.remove(old_data) + new_data = OxmlElement("cx:data") + new_data.set("id", "0") + chartData.append(new_data) + new_data.add_string_dimension("cat", chart_data.categories_ref, chart_data.categories) + new_data.add_numeric_dimension( + "val", + chart_data.values_ref, + chart_data.series_values, + chart_data.number_format, + ) + + # --- update series name --- + series_elems = self._chart.plotArea.plotAreaRegion.series_lst + if series_elems: + series_elem = series_elems[0] + tx = series_elem.tx + if tx is not None: + txData = tx.txData + if txData is not None: + f_elem = txData.f + if f_elem is not None: + f_elem.text = chart_data.series_name_ref + v_elem = txData.v + if v_elem is not None: + v_elem.text = chart_data.series_name + + # --- replace subtotals on layoutPr --- + series_elem._remove_layoutPr() + if chart_data.subtotals: + layoutPr = series_elem._add_layoutPr() + subtotals_elem = OxmlElement("cx:subtotals") + layoutPr._insert_subtotals(subtotals_elem) + for idx in chart_data.subtotals: + idx_elem = OxmlElement("cx:idx") + idx_elem.set("val", str(idx)) + subtotals_elem.append(idx_elem) + + # --- remove dataPt elements that reference out-of-range indices --- + num_points = len(chart_data.categories) + for dataPt in list(series_elem.dataPt_lst): + idx = dataPt.get("idx") + if idx is not None and int(idx) >= num_points: + series_elem.remove(dataPt) + + # --- update the embedded Excel workbook --- + self.part.chartex_workbook.update_from_xlsx_blob(chart_data.xlsx_blob) + + @property + def _chart(self): + """The ```` element in this chart.""" + return self._chartspace.chart + + +class Legend(ParentedElementProxy): + """Chart legend object. + + Corresponds to the ```` element in chartex. + """ + + @property + def position(self) -> str | None: + """ + Return the position of the legend as a string, or None if the position + is not specified. Valid values are 'l', 't', 'r', 'b' indicating left, + top, right, or bottom. + """ + pos = self._element.get("pos") + if pos is None: + return None + return pos + + @position.setter + def position(self, value): + """ + Set the position of the legend to one of 'l', 't', 'r', or 'b'. + """ + valid_positions = {"l", "t", "r", "b"} + if value not in valid_positions: + raise ValueError(f"position must be one of {', '.join(valid_positions)}") + self._element.set("pos", value) + + @property + def include_in_layout(self) -> bool: + """ + Return True if the legend's position is affected by the chart layout, + False otherwise. + """ + overlay = self._element.get("overlay") + if overlay is None: + return True + return overlay == "0" + + @include_in_layout.setter + def include_in_layout(self, value): + """ + Set whether the legend's position is affected by the chart layout. + """ + self._element.set("overlay", "0" if value else "1") + + +class Series(ParentedElementProxy): + """Chart series object. + + Corresponds to the ```` element in chartex. + """ + + _series: CT_Series + + def __init__(self, series: CT_Series, parent: ChartEx): + super().__init__(series, parent) + self._series = series + + @property + def name(self) -> str | None: + """The name of this series, or None if it has no name.""" + tx = self._series.tx + if tx is None: + return None + + tx_text = tx.find( + ".//cx:v", namespaces={"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex"} + ) + if tx_text is None: + return None + + return tx_text.text + + @property + def is_visible(self) -> bool: + """True if this series is visible, False otherwise.""" + hidden = self._series.get("hidden") + if hidden is None: + return True + return hidden == "0" + + @is_visible.setter + def is_visible(self, value): + """Set whether this series is visible.""" + self._series.set("hidden", "0" if value else "1") + + @property + def values(self) -> list[float | None]: + """The data values for this series.""" + + data_id_elem = self._series.dataId + if data_id_elem is None: + return [] + data_id = data_id_elem.val + # Navigate up from series to chartSpace, then find chartData + chartSpace = self._series.getparent() + while chartSpace is not None and chartSpace.tag != qn("cx:chartSpace"): + chartSpace = chartSpace.getparent() + if chartSpace is None: + return [] + chartData = chartSpace.chartData + for data_elem in chartData.data_lst: + if data_elem.id == data_id: + for numDim in data_elem.numDim_lst: + result: list[float | None] = [] + for lvl in numDim.lvl_lst: + pt_count = int(lvl.get("ptCount", "0")) + values: list[float | None] = [None] * pt_count + for pt in lvl: + idx = int(pt.get("idx", "0")) + if idx < pt_count and pt.text is not None: + values[idx] = float(pt.text) + result.extend(values) + return result + return [] + + +class Axis(ParentedElementProxy): + """Chart axis object. + + Corresponds to the ```` element in chartex. + """ + + _axis: CT_Axis + + def __init__(self, axis: CT_Axis, parent: ChartEx): + super().__init__(axis, parent) + self._axis = axis + + @property + def id(self) -> int: + """The id of this axis.""" + return int(self._axis.get("id")) + + @property + def is_visible(self) -> bool: + """True if this axis is visible, False otherwise.""" + hidden = self._axis.get("hidden") + if hidden is None: + return True + return hidden == "0" + + @is_visible.setter + def is_visible(self, value): + """Set whether this axis is visible.""" + self._axis.set("hidden", "0" if value else "1") + + @property + def has_major_gridlines(self) -> bool: + """True if this axis has major gridlines, False otherwise.""" + return self._axis.majorGridlines is not None + + @property + def has_minor_gridlines(self) -> bool: + """True if this axis has minor gridlines, False otherwise.""" + return self._axis.minorGridlines is not None + + @property + def title(self) -> str | None: + """The title of this axis, or None if it has no title.""" + title = self._axis.title + if title is None: + return None + + title_text = title.find( + ".//cx:v", namespaces={"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex"} + ) + if title_text is None: + return None + + return title_text.text diff --git a/src/pptx/chart/data.py b/src/pptx/chart/data.py index c25ff9349..42b48acb8 100644 --- a/src/pptx/chart/data.py +++ b/src/pptx/chart/data.py @@ -858,3 +858,111 @@ def bubble_size(self): The value representing the size of the bubble for this data point. """ return self._size + + +class WaterfallChartData: + """Data container for a ChartEx waterfall chart. + + Used as a parameter in :meth:`ChartEx.replace_data` to update the data displayed + by a waterfall chart. Categories appear in column A of the embedded Excel worksheet + and values in column B. + + Example usage:: + + chart_data = WaterfallChartData() + chart_data.categories = ['Q1', 'Q2', 'Q3', 'Q4', 'Total'] + chart_data.add_series('Revenue', [100, 50, -30, 80, 200], subtotals=[4]) + chartex.replace_data(chart_data) + """ + + def __init__(self, number_format="General"): + self._categories = [] + self._series_name = None + self._series_values = [] + self._subtotals = [] + self._number_format = number_format + + @property + def categories(self): + """The category labels for this chart as a list of strings.""" + return self._categories + + @categories.setter + def categories(self, value): + self._categories = list(value) + + def add_series(self, name, values, subtotals=None): + """Add a series with *name*, *values*, and optional *subtotals* indices. + + *subtotals* is a list of zero-based category indices that represent subtotal + (or total) bars in the waterfall chart. Only one series is supported per + waterfall chart; calling this method a second time replaces the previous series. + """ + self._series_name = name + self._series_values = list(values) + self._subtotals = list(subtotals) if subtotals else [] + + @property + def series_name(self): + """The name of the series, or None if no series has been added.""" + return self._series_name + + @property + def series_values(self): + """The numeric values for the series.""" + return self._series_values + + @property + def subtotals(self): + """Zero-based indices of categories that are subtotals.""" + return self._subtotals + + @property + def number_format(self): + """The number format string applied to values, e.g. ``'#,##0'``.""" + return self._number_format + + @property + def categories_ref(self): + """Excel worksheet reference for the category cells.""" + n = len(self._categories) + return "Sheet1!$A$2:$A$%d" % (n + 1) + + @property + def values_ref(self): + """Excel worksheet reference for the value cells.""" + n = len(self._categories) + return "Sheet1!$B$2:$B$%d" % (n + 1) + + @property + def series_name_ref(self): + """Excel worksheet reference for the series name cell.""" + return "Sheet1!$B$1" + + @property + def xlsx_blob(self): + """A bytes object containing an Excel workbook with the chart data.""" + import io + + from xlsxwriter import Workbook + + xlsx_file = io.BytesIO() + workbook = Workbook(xlsx_file, {"in_memory": True}) + worksheet = workbook.add_worksheet("Sheet1") + + # Header row + worksheet.write(0, 0, "Category") + worksheet.write(0, 1, self._series_name or "Series 1") + + # Data rows + if len(self._categories) != len(self._series_values): + raise ValueError( + f"categories length ({len(self._categories)}) must equal" + f" series values length ({len(self._series_values)})" + ) + for idx, (cat, val) in enumerate(zip(self._categories, self._series_values)): + worksheet.write(idx + 1, 0, cat) + worksheet.write(idx + 1, 1, val) + + workbook.close() + return xlsx_file.getvalue() diff --git a/src/pptx/enum/chart.py b/src/pptx/enum/chart.py index 2599cf4dd..bc95cd71a 100644 --- a/src/pptx/enum/chart.py +++ b/src/pptx/enum/chart.py @@ -290,6 +290,34 @@ class XL_CHART_TYPE(BaseEnum): XY_SCATTER_SMOOTH_NO_MARKERS = (73, "Scatter with Smoothed Lines and No Data Markers.") """Scatter with Smoothed Lines and No Data Markers.""" + # -- Office 2016 modern chart family (ChartEx / `cx:` namespace). These are not + # -- part of the MS COM `XlChartType` enumeration (which predates chartEx); the + # -- integer values are fork-specific extensions in a private high range chosen + # -- to avoid collision with any MS value. See issue #14. Only WATERFALL has a + # -- writer; the others are enum + round-trip-preservation only (writers tracked + # -- as a follow-up issue). + + WATERFALL = (1001, "Waterfall (ChartEx). Office 2016+.") + """Waterfall (ChartEx). Office 2016+.""" + + TREEMAP = (1002, "Treemap (ChartEx). Office 2016+. Round-trip only.") + """Treemap (ChartEx). Office 2016+. Round-trip preservation only.""" + + SUNBURST = (1003, "Sunburst (ChartEx). Office 2016+. Round-trip only.") + """Sunburst (ChartEx). Office 2016+. Round-trip preservation only.""" + + FUNNEL = (1004, "Funnel (ChartEx). Office 2016+. Round-trip only.") + """Funnel (ChartEx). Office 2016+. Round-trip preservation only.""" + + BOX_WHISKER = (1005, "Box & Whisker (ChartEx). Office 2016+. Round-trip only.") + """Box & Whisker (ChartEx). Office 2016+. Round-trip preservation only.""" + + HISTOGRAM = (1006, "Histogram (ChartEx). Office 2016+. Round-trip only.") + """Histogram (ChartEx). Office 2016+. Round-trip preservation only.""" + + PARETO = (1007, "Pareto / Histogram-Pareto (ChartEx). Office 2016+. Round-trip only.") + """Pareto (ChartEx). Office 2016+. Round-trip preservation only.""" + class XL_DATA_LABEL_POSITION(BaseXmlEnum): """Specifies where the data label is positioned. diff --git a/src/pptx/opc/constants.py b/src/pptx/opc/constants.py index 8738d3867..41a79d058 100644 --- a/src/pptx/opc/constants.py +++ b/src/pptx/opc/constants.py @@ -215,8 +215,10 @@ class RELATIONSHIP_TYPE: "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/certificate" ) CHART = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" + CHARTEX = "http://schemas.microsoft.com/office/2014/relationships/chartEx" CHARTSHEET = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" CHART_COLOR_STYLE = "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" + CHART_STYLE = "http://schemas.microsoft.com/office/2011/relationships/chartStyle" CHART_USER_SHAPES = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartUserShapes" ) diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index b9e70c6e0..110c36d55 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -583,3 +583,64 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): from pptx.oxml.theme import CT_OfficeStyleSheet # noqa: E402 register_element_cls("a:theme", CT_OfficeStyleSheet) + + +from pptx.oxml.chart.chartex import ( # noqa: E402 + CT_Axis, + CT_CategoryAxisScaling, + CT_Chart, + CT_ChartData, + CT_ChartSpace, + CT_ChartTitle, + CT_Data, + CT_DataId, + CT_DataLabels, + CT_DataLabelVisibilities, + CT_ExternalData, + CT_Formula, + CT_Gridlines, + CT_Legend, + CT_NumericDimension, + CT_PlotArea, + CT_PlotAreaRegion, + CT_Series, + CT_SeriesLayoutProperties, + CT_StringDimension, + CT_StringLevel, + CT_StringValue, + CT_SubtotalIndex, + CT_Subtotals, + CT_Text, + CT_TextData, + CT_TickLabels, + CT_ValueAxisScaling, +) + +register_element_cls("cx:axis", CT_Axis) +register_element_cls("cx:catScaling", CT_CategoryAxisScaling) +register_element_cls("cx:chart", CT_Chart) +register_element_cls("cx:chartData", CT_ChartData) +register_element_cls("cx:chartSpace", CT_ChartSpace) +register_element_cls("cx:data", CT_Data) +register_element_cls("cx:dataId", CT_DataId) +register_element_cls("cx:dataLabels", CT_DataLabels) +register_element_cls("cx:externalData", CT_ExternalData) +register_element_cls("cx:f", CT_Formula) +register_element_cls("cx:idx", CT_SubtotalIndex) +register_element_cls("cx:layoutPr", CT_SeriesLayoutProperties) +register_element_cls("cx:legend", CT_Legend) +register_element_cls("cx:lvl", CT_StringLevel) +register_element_cls("cx:majorGridlines", CT_Gridlines) +register_element_cls("cx:numDim", CT_NumericDimension) +register_element_cls("cx:plotArea", CT_PlotArea) +register_element_cls("cx:plotAreaRegion", CT_PlotAreaRegion) +register_element_cls("cx:pt", CT_StringValue) +register_element_cls("cx:series", CT_Series) +register_element_cls("cx:strDim", CT_StringDimension) +register_element_cls("cx:subtotals", CT_Subtotals) +register_element_cls("cx:tickLabels", CT_TickLabels) +register_element_cls("cx:title", CT_ChartTitle) +register_element_cls("cx:tx", CT_Text) +register_element_cls("cx:txData", CT_TextData) +register_element_cls("cx:valScaling", CT_ValueAxisScaling) +register_element_cls("cx:visibility", CT_DataLabelVisibilities) diff --git a/src/pptx/oxml/chart/chartex.py b/src/pptx/oxml/chart/chartex.py new file mode 100644 index 000000000..6ff13a45e --- /dev/null +++ b/src/pptx/oxml/chart/chartex.py @@ -0,0 +1,593 @@ +"""XML elements for chart extensions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pptx.oxml.ns import qn +from pptx.oxml.simpletypes import XsdBoolean, XsdInt, XsdString +from pptx.oxml.xmlchemy import ( + BaseOxmlElement, + OneAndOnlyOne, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, +) + +if TYPE_CHECKING: + pass + + +class CT_ChartSpace(BaseOxmlElement): + """ + ```` element, the root element of a chartex part. + """ + + chartData: CT_ChartData = OneAndOnlyOne("cx:chartData") # pyright: ignore + chart: CT_Chart = OneAndOnlyOne("cx:chart") # pyright: ignore + spPr = ZeroOrOne("cx:spPr") # Shape properties + txPr = ZeroOrOne("cx:txPr") # Text properties + extLst = ZeroOrOne("cx:extLst") # Extension list + + @classmethod + def new(cls): + """ + Return a new ```` element + """ + from pptx.oxml import parse_xml + + xml = ( + b"' + b"" + ) + chartSpace = cast(CT_ChartSpace, parse_xml(xml)) + chartData = CT_ChartData.new() + chart = CT_Chart.new() + chartSpace.append(chartData) + chartSpace.append(chart) + return chartSpace + + @property + def xlsx_part_rId(self) -> str | None: + """ + The rId of the embedded Excel part relationship, or None if no + embedded Excel part is present. + """ + externalData = self.chartData.find(qn("cx:externalData")) + if externalData is None: + return None + rId = externalData.get(qn("r:id")) + if rId is None: + return None + return rId + + def get_or_add_externalData(self): + """ + Return the child element, newly created if not + present. + """ + return self.chartData.get_or_add_externalData() + + +class CT_ChartData(BaseOxmlElement): + """ + ```` element, container for chart data and external data reference. + """ + + externalData: CT_ExternalData = ZeroOrOne("cx:externalData") # pyright: ignore + data = ZeroOrMore("cx:data") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + @classmethod + def new(cls): + """Return a new element.""" + from lxml import etree + + from pptx.oxml import parse_xml + + xml = b'' + chartData = cast(CT_ChartData, parse_xml(xml)) + # Add a default data element with id="0" + data_elem = etree.SubElement(chartData, qn("cx:data")) + data_elem.set("id", "0") + return chartData + + def get_or_add_externalData(self): + """Return the child element, newly created if not present.""" + from lxml import etree + + externalData = self.find(qn("cx:externalData")) + if externalData is None: + externalData = etree.SubElement(self, qn("cx:externalData")) + # Move it to the beginning + self.remove(externalData) + self.insert(0, externalData) + return externalData + + +class CT_ExternalData(BaseOxmlElement): + """ + ```` element, refers to external Excel data. + """ + + autoUpdate: bool = OptionalAttribute("autoUpdate", XsdBoolean) # pyright: ignore + rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore + + +class CT_Chart(BaseOxmlElement): + """ + ```` element, container for chart elements. + """ + + title: CT_ChartTitle = ZeroOrOne("cx:title") # pyright: ignore + plotArea: CT_PlotArea = OneAndOnlyOne("cx:plotArea") # pyright: ignore + legend: CT_Legend = ZeroOrOne("cx:legend") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + @classmethod + def new(cls): + """Return a new element.""" + from pptx.oxml import parse_xml + + xml = b'' + chart = cast(CT_Chart, parse_xml(xml)) + plotArea = CT_PlotArea.new() + chart.append(plotArea) + return chart + + def get_or_add_legend(self): + """Return the child element, newly created if not present.""" + from lxml import etree + + legend = self.find(qn("cx:legend")) + if legend is None: + legend = etree.SubElement(self, qn("cx:legend")) + # Insert legend after plotArea + plot_area_idx = list(self).index(self.plotArea) + self.remove(legend) + self.insert(plot_area_idx + 1, legend) + # Set default attributes + legend.set("pos", "t") + legend.set("align", "ctr") + legend.set("overlay", "0") + return legend + + def get_or_add_title(self): + """Return the child element, newly created if not present.""" + from lxml import etree + + title = self.find(qn("cx:title")) + if title is None: + title = etree.SubElement(self, qn("cx:title")) + # Move title to the beginning + self.remove(title) + self.insert(0, title) + # Set default attributes + title.set("pos", "t") + title.set("align", "ctr") + title.set("overlay", "0") + return title + + +class CT_PlotArea(BaseOxmlElement): + """ + ```` element, container for chart plot area. + """ + + plotAreaRegion: CT_PlotAreaRegion = OneAndOnlyOne("cx:plotAreaRegion") # pyright: ignore + axis = ZeroOrMore("cx:axis") # pyright: ignore + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + @classmethod + def new(cls): + """Return a new element with default axes.""" + from pptx.oxml import parse_xml + + xml = b'' + plotArea = cast(CT_PlotArea, parse_xml(xml)) + plotAreaRegion = CT_PlotAreaRegion.new() + plotArea.append(plotAreaRegion) + # Add category axis (id=0) + cat_axis = CT_Axis.new_cat_axis(0) + plotArea.append(cat_axis) + # Add value axis (id=1) + val_axis = CT_Axis.new_val_axis(1) + plotArea.append(val_axis) + return plotArea + + +class CT_PlotAreaRegion(BaseOxmlElement): + """ + ```` element, container for a plot area region. + """ + + plotSurface = ZeroOrOne("cx:plotSurface") # pyright: ignore + series = ZeroOrMore("cx:series") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + @classmethod + def new(cls): + """Return a new element.""" + from pptx.oxml import parse_xml + + xml = b'' + return cast(CT_PlotAreaRegion, parse_xml(xml)) + + def add_waterfall_series( + self, series_name: str, data_id: int = 0, subtotal_indices: list[int] | None = None + ): + """Add a waterfall series to this plot area region.""" + import uuid + + from lxml import etree + + series = etree.SubElement(self, qn("cx:series")) + series.set("layoutId", "waterfall") + series.set("uniqueId", f"{{{uuid.uuid4()}}}") + + # Add series title + tx = etree.SubElement(series, qn("cx:tx")) + txData = etree.SubElement(tx, qn("cx:txData")) + f_elem = etree.SubElement(txData, qn("cx:f")) + f_elem.text = "Sheet1!$B$1" + v_elem = etree.SubElement(txData, qn("cx:v")) + v_elem.text = series_name + + # Add data labels + dataLabels = etree.SubElement(series, qn("cx:dataLabels")) + dataLabels.set("pos", "outEnd") + visibility = etree.SubElement(dataLabels, qn("cx:visibility")) + visibility.set("seriesName", "0") + visibility.set("categoryName", "0") + visibility.set("value", "1") + + # Add data id + dataId_elem = etree.SubElement(series, qn("cx:dataId")) + dataId_elem.set("val", str(data_id)) + + # Add layout properties with subtotals + if subtotal_indices: + layoutPr = etree.SubElement(series, qn("cx:layoutPr")) + subtotals = etree.SubElement(layoutPr, qn("cx:subtotals")) + for idx in subtotal_indices: + idx_elem = etree.SubElement(subtotals, qn("cx:idx")) + idx_elem.set("val", str(idx)) + + return series + + +class CT_Series(BaseOxmlElement): + """ + ```` element, container for a chart series. + """ + + tx = ZeroOrOne("cx:tx") # pyright: ignore + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + valueColors = ZeroOrOne("cx:valueColors") # pyright: ignore + valueColorPositions = ZeroOrOne("cx:valueColorPositions") # pyright: ignore + dataPt = ZeroOrMore("cx:dataPt") # pyright: ignore + dataLabels = ZeroOrOne("cx:dataLabels") # pyright: ignore + dataId = ZeroOrOne("cx:dataId") # pyright: ignore + layoutPr = ZeroOrOne("cx:layoutPr") # pyright: ignore + axisId = ZeroOrMore("cx:axisId") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + layoutId: XsdString = RequiredAttribute("layoutId", XsdString) # pyright: ignore + hidden: bool = OptionalAttribute("hidden", XsdBoolean, False) # pyright: ignore + ownerIdx: int = OptionalAttribute("ownerIdx", XsdInt) # pyright: ignore + uniqueId: str = OptionalAttribute("uniqueId", XsdString) # pyright: ignore + formatIdx: int = OptionalAttribute("formatIdx", XsdInt) # pyright: ignore + + +class CT_Axis(BaseOxmlElement): + """ + ```` element, represents an axis in the chart. + """ + + catScaling: CT_CategoryAxisScaling = ZeroOrOne("cx:catScaling") # pyright: ignore + valScaling: CT_ValueAxisScaling = ZeroOrOne("cx:valScaling") # pyright: ignore + title = ZeroOrOne("cx:title") # pyright: ignore + units = ZeroOrOne("cx:units") # pyright: ignore + majorGridlines: CT_Gridlines = ZeroOrOne("cx:majorGridlines") # pyright: ignore + minorGridlines = ZeroOrOne("cx:minorGridlines") # pyright: ignore + majorTickMarks = ZeroOrOne("cx:majorTickMarks") # pyright: ignore + minorTickMarks = ZeroOrOne("cx:minorTickMarks") # pyright: ignore + tickLabels: CT_TickLabels = ZeroOrOne("cx:tickLabels") # pyright: ignore + numFmt = ZeroOrOne("cx:numFmt") # pyright: ignore + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + txPr = ZeroOrOne("cx:txPr") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + id: int = RequiredAttribute("id", XsdInt) # pyright: ignore + hidden: bool = OptionalAttribute("hidden", XsdBoolean, False) # pyright: ignore + + @classmethod + def new_cat_axis(cls, axis_id: int): + """Return a new category axis element.""" + from lxml import etree + + from pptx.oxml import parse_xml + + xml = b'' + axis = cast(CT_Axis, parse_xml(xml)) + axis.set("id", str(axis_id)) + # Add catScaling element + catScaling = etree.SubElement(axis, qn("cx:catScaling")) + catScaling.set("gapWidth", "0.5") + # Add tickLabels + etree.SubElement(axis, qn("cx:tickLabels")) + return axis + + @classmethod + def new_val_axis(cls, axis_id: int): + """Return a new value axis element.""" + from lxml import etree + + from pptx.oxml import parse_xml + + xml = b'' + axis = cast(CT_Axis, parse_xml(xml)) + axis.set("id", str(axis_id)) + # Add valScaling element + etree.SubElement(axis, qn("cx:valScaling")) + # Add majorGridlines + etree.SubElement(axis, qn("cx:majorGridlines")) + # Add tickLabels + etree.SubElement(axis, qn("cx:tickLabels")) + return axis + + +class CT_Text(BaseOxmlElement): + """ + ```` element, container for series text. + """ + + txData = ZeroOrOne("cx:txData") # pyright: ignore + + +class CT_TextData(BaseOxmlElement): + """ + ```` element, contains series text data. + """ + + f = ZeroOrOne("cx:f") # pyright: ignore + v = ZeroOrOne("cx:v") # pyright: ignore + + +class CT_DataLabels(BaseOxmlElement): + """ + ```` element, container for data labels. + """ + + numFmt = ZeroOrOne("cx:numFmt") # pyright: ignore + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + txPr = ZeroOrOne("cx:txPr") # pyright: ignore + visibility = ZeroOrOne("cx:visibility") # pyright: ignore + separator = ZeroOrOne("cx:separator") # pyright: ignore + dataLabel = ZeroOrMore("cx:dataLabel") # pyright: ignore + dataLabelHidden = ZeroOrMore("cx:dataLabelHidden") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + +class CT_DataId(BaseOxmlElement): + """ + ```` element, identifies the data for a series. + """ + + val: int = RequiredAttribute("val", XsdInt) # pyright: ignore + + +class CT_Data(BaseOxmlElement): + """ + ```` element, contains data dimensions. + """ + + strDim = ZeroOrMore("cx:strDim") # pyright: ignore + numDim = ZeroOrMore("cx:numDim") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + id: int = RequiredAttribute("id", XsdInt) # pyright: ignore + + def add_string_dimension(self, dim_type: str, formula: str, values: list[str]): + """Add a string dimension with the given type, formula, and values.""" + from lxml import etree + + strDim = etree.SubElement(self, qn("cx:strDim")) + strDim.set("type", dim_type) + + # Add formula + f_elem = etree.SubElement(strDim, qn("cx:f")) + f_elem.text = formula + + # Add level with points + lvl = etree.SubElement(strDim, qn("cx:lvl")) + lvl.set("ptCount", str(len(values))) + + for idx, value in enumerate(values): + pt = etree.SubElement(lvl, qn("cx:pt")) + pt.set("idx", str(idx)) + pt.text = value + + return strDim + + def add_numeric_dimension( + self, dim_type: str, formula: str, values: list[float | int], format_code: str = "General" + ): + """Add a numeric dimension with the given type, formula, and values.""" + from lxml import etree + + numDim = etree.SubElement(self, qn("cx:numDim")) + numDim.set("type", dim_type) + + # Add formula + f_elem = etree.SubElement(numDim, qn("cx:f")) + f_elem.text = formula + + # Add level with points + lvl = etree.SubElement(numDim, qn("cx:lvl")) + lvl.set("ptCount", str(len(values))) + lvl.set("formatCode", format_code) + + for idx, value in enumerate(values): + pt = etree.SubElement(lvl, qn("cx:pt")) + pt.set("idx", str(idx)) + pt.text = str(value) + + return numDim + + +class CT_StringDimension(BaseOxmlElement): + """ + ```` element, string dimension for chart data. + """ + + f = ZeroOrOne("cx:f") # pyright: ignore + nf = ZeroOrOne("cx:nf") # pyright: ignore + lvl = ZeroOrMore("cx:lvl") # pyright: ignore + + type: XsdString = RequiredAttribute("type", XsdString) # pyright: ignore + + +class CT_NumericDimension(BaseOxmlElement): + """ + ```` element, numeric dimension for chart data. + """ + + f = ZeroOrOne("cx:f") # pyright: ignore + nf = ZeroOrOne("cx:nf") # pyright: ignore + lvl = ZeroOrMore("cx:lvl") # pyright: ignore + + type: XsdString = RequiredAttribute("type", XsdString) # pyright: ignore + + +class CT_Formula(BaseOxmlElement): + """ + ```` element, formula reference. + """ + + dir: XsdString = OptionalAttribute("dir", XsdString, "col") # pyright: ignore + + +class CT_StringLevel(BaseOxmlElement): + """ + ```` element for string dimensions. + """ + + pt = ZeroOrMore("cx:pt") # pyright: ignore + + ptCount: int = RequiredAttribute("ptCount", XsdInt) # pyright: ignore + name: XsdString = OptionalAttribute("name", XsdString) # pyright: ignore + formatCode: XsdString = OptionalAttribute("formatCode", XsdString) # pyright: ignore + + +class CT_StringValue(BaseOxmlElement): + """ + ```` element for string values. + """ + + idx: int = RequiredAttribute("idx", XsdInt) # pyright: ignore + + +class CT_CategoryAxisScaling(BaseOxmlElement): + """ + ```` element, category axis scaling. + """ + + gapWidth: XsdString = OptionalAttribute("gapWidth", XsdString) # pyright: ignore + + +class CT_ValueAxisScaling(BaseOxmlElement): + """ + ```` element, value axis scaling. + """ + + max: XsdString = OptionalAttribute("max", XsdString) # pyright: ignore + min: XsdString = OptionalAttribute("min", XsdString) # pyright: ignore + majorUnit: XsdString = OptionalAttribute("majorUnit", XsdString) # pyright: ignore + minorUnit: XsdString = OptionalAttribute("minorUnit", XsdString) # pyright: ignore + + +class CT_SeriesLayoutProperties(BaseOxmlElement): + """ + ```` element, series layout properties. + """ + + subtotals = ZeroOrOne("cx:subtotals") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + +class CT_Subtotals(BaseOxmlElement): + """ + ```` element, waterfall chart subtotals. + """ + + idx = ZeroOrMore("cx:idx") # pyright: ignore + + +class CT_SubtotalIndex(BaseOxmlElement): + """ + ```` element in subtotals. + """ + + val: int = RequiredAttribute("val", XsdInt) # pyright: ignore + + +class CT_TickLabels(BaseOxmlElement): + """ + ```` element, axis tick labels. + """ + + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + +class CT_ChartTitle(BaseOxmlElement): + """ + ```` element for chart title. + """ + + tx = ZeroOrOne("cx:tx") # pyright: ignore + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + txPr = ZeroOrOne("cx:txPr") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + pos: XsdString = OptionalAttribute("pos", XsdString, "t") # pyright: ignore + align: XsdString = OptionalAttribute("align", XsdString, "ctr") # pyright: ignore + overlay: bool = OptionalAttribute("overlay", XsdBoolean, False) # pyright: ignore + + +class CT_Legend(BaseOxmlElement): + """ + ```` element for chart legend. + """ + + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + txPr = ZeroOrOne("cx:txPr") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + pos: XsdString = OptionalAttribute("pos", XsdString, "r") # pyright: ignore + align: XsdString = OptionalAttribute("align", XsdString, "ctr") # pyright: ignore + overlay: bool = OptionalAttribute("overlay", XsdBoolean, False) # pyright: ignore + + +class CT_Gridlines(BaseOxmlElement): + """ + ```` and ```` elements. + """ + + spPr = ZeroOrOne("cx:spPr") # pyright: ignore + extLst = ZeroOrOne("cx:extLst") # pyright: ignore + + +class CT_DataLabelVisibilities(BaseOxmlElement): + """ + ```` element for data label visibility. + """ + + seriesName: bool = OptionalAttribute("seriesName", XsdBoolean) # pyright: ignore + categoryName: bool = OptionalAttribute("categoryName", XsdBoolean) # pyright: ignore + value: bool = OptionalAttribute("value", XsdBoolean) # pyright: ignore diff --git a/src/pptx/oxml/ns.py b/src/pptx/oxml/ns.py index de33a8480..700a7de20 100644 --- a/src/pptx/oxml/ns.py +++ b/src/pptx/oxml/ns.py @@ -9,6 +9,7 @@ "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", "ct": "http://schemas.openxmlformats.org/package/2006/content-types", + "cx": "http://schemas.microsoft.com/office/drawing/2014/chartex", "dc": "http://purl.org/dc/elements/1.1/", "dcmitype": "http://purl.org/dc/dcmitype/", "dcterms": "http://purl.org/dc/terms/", @@ -16,6 +17,7 @@ "ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties", "i": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", "m": "http://schemas.openxmlformats.org/officeDocument/2006/math", + "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", "mo": "http://schemas.microsoft.com/office/mac/office/2008/main", "mv": "urn:schemas-microsoft-com:mac:vml", "o": "urn:schemas-microsoft-com:office:office", diff --git a/src/pptx/oxml/shapes/graphfrm.py b/src/pptx/oxml/shapes/graphfrm.py index efa0b3632..e91904e58 100644 --- a/src/pptx/oxml/shapes/graphfrm.py +++ b/src/pptx/oxml/shapes/graphfrm.py @@ -6,7 +6,7 @@ from pptx.oxml import parse_xml from pptx.oxml.chart.chart import CT_Chart -from pptx.oxml.ns import nsdecls +from pptx.oxml.ns import nsdecls, qn from pptx.oxml.shapes.shared import BaseShapeElement from pptx.oxml.simpletypes import XsdBoolean, XsdString from pptx.oxml.table import CT_Table @@ -19,6 +19,7 @@ ) from pptx.spec import ( GRAPHIC_DATA_URI_CHART, + GRAPHIC_DATA_URI_CHARTEX, GRAPHIC_DATA_URI_OLEOBJ, GRAPHIC_DATA_URI_TABLE, ) @@ -146,6 +147,19 @@ def chart_rId(self) -> str | None: return None return chart.rId + @property + def chartex_rId(self) -> str | None: + """The `r:id` attribute of the `cx:chart` great-grandchild element. + + |None| if not present. + """ + if self.graphicData_uri != GRAPHIC_DATA_URI_CHARTEX: + return None + chartex_elem = self.graphic.graphicData.findall(qn("cx:chart")) + if not chartex_elem: + return None + return chartex_elem[0].get(qn("r:id")) + def get_or_add_xfrm(self) -> CT_Transform2D: """Return the required `p:xfrm` child element. @@ -188,6 +202,19 @@ def new_chart_graphicFrame( graphicData.append(CT_Chart.new_chart(rId)) return graphicFrame + @classmethod + def new_chartex_graphicFrame( + cls, id_: int, name: str, rId: str, x: int, y: int, cx: int, cy: int + ) -> CT_GraphicalObjectFrame: + """Return a `p:graphicFrame` element tree populated with a chartex element.""" + graphicFrame = CT_GraphicalObjectFrame.new_graphicFrame(id_, name, x, y, cx, cy) + graphicData = graphicFrame.graphic.graphicData + graphicData.uri = GRAPHIC_DATA_URI_CHARTEX + + chart_elem = parse_xml(f'') + graphicData.append(chart_elem) + return graphicFrame + @classmethod def new_graphicFrame( cls, id_: int, name: str, x: int, y: int, cx: int, cy: int diff --git a/src/pptx/oxml/shapes/groupshape.py b/src/pptx/oxml/shapes/groupshape.py index 33c94d49a..948e919b2 100644 --- a/src/pptx/oxml/shapes/groupshape.py +++ b/src/pptx/oxml/shapes/groupshape.py @@ -141,11 +141,20 @@ def iter_ph_elms(self): def iter_shape_elms(self) -> Iterator[ShapeElement]: """Generate each child of this `p:spTree` element that corresponds to a shape. - Items appear in XML document order. + Items appear in XML document order. Shape elements wrapped in + `mc:AlternateContent` (e.g. ChartEx) are unwrapped from the `mc:Choice` branch. """ + mc_alternate_content_tag = qn("mc:AlternateContent") + mc_choice_tag = qn("mc:Choice") for elm in self.iterchildren(): if elm.tag in self._shape_tags: yield elm + elif elm.tag == mc_alternate_content_tag: + choice = elm.find(mc_choice_tag) + if choice is not None: + for child in choice: + if child.tag in self._shape_tags: + yield child @property def max_shape_id(self) -> int: diff --git a/src/pptx/parts/chartex.py b/src/pptx/parts/chartex.py new file mode 100644 index 000000000..483437c78 --- /dev/null +++ b/src/pptx/parts/chartex.py @@ -0,0 +1,350 @@ +"""Chart Extension part objects.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pptx.chart.chartex import ChartEx +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.opc.package import XmlPart +from pptx.oxml.chart.chartex import CT_ChartSpace +from pptx.parts.embeddedpackage import EmbeddedXlsxPart +from pptx.util import lazyproperty + +if TYPE_CHECKING: + from pptx.chart.data import ChartData + from pptx.package import Package + + +class ChartExPart(XmlPart): + """A chart extension part. + + Corresponds to parts having partnames matching ppt/charts/chartEx[1-9][0-9]*.xml + """ + + partname_template = "/ppt/charts/chartEx%d.xml" + + @classmethod + def new(cls, package: Package, chart_type: str = "waterfall"): + """Return new |ChartExPart| instance added to `package`.""" + chartex_part = cls.load( + package.next_partname(cls.partname_template), + CT.OFC_CHART_EX, + package, + b"", + ) + # Initialize default chart space + chartex_part._element = CT_ChartSpace.new() + + # Add chart style and color style parts + style_part = ChartStylePart.new(package) + chartex_part.relate_to(style_part, RT.CHART_STYLE) + + color_style_part = ChartColorStylePart.new(package) + chartex_part.relate_to(color_style_part, RT.CHART_COLOR_STYLE) + + return chartex_part + + def add_excel_data(self, chart_data: ChartData): + """Add Excel workbook data from the specified chart data object.""" + xlsx_blob = chart_data.xlsx_blob + self.chartex_workbook.update_from_xlsx_blob(xlsx_blob) + + @lazyproperty + def chartex(self) -> ChartEx: + """The |ChartEx| object representing this chart extension.""" + return ChartEx(self._element, self) + + @lazyproperty + def chartex_workbook(self) -> ChartExWorkbook: + """ + The |ChartExWorkbook| object providing access to the external chart + data in a linked or embedded Excel workbook. + """ + return ChartExWorkbook(self._element, self) + + +class ChartStylePart(XmlPart): + """A chart style part (style1.xml). + + Contains chart styling information for ChartEx charts. + """ + + partname_template = "/ppt/charts/style%d.xml" + + @classmethod + def new(cls, package: Package) -> ChartStylePart: + """Return new |ChartStylePart| with default chart style XML.""" + return cls.load( + package.next_partname(cls.partname_template), + CT.OFC_CHART_STYLE, + package, + _CHART_STYLE_XML, + ) + + +class ChartColorStylePart(XmlPart): + """A chart color style part (colors1.xml). + + Contains chart color styling information for ChartEx charts. + """ + + partname_template = "/ppt/charts/colors%d.xml" + + @classmethod + def new(cls, package: Package) -> ChartColorStylePart: + """Return new |ChartColorStylePart| with default chart color style XML.""" + return cls.load( + package.next_partname(cls.partname_template), + CT.OFC_CHART_COLORS, + package, + _CHART_COLOR_STYLE_XML, + ) + + +class ChartExWorkbook(object): + """Provides access to external chart data in a linked or embedded Excel workbook for ChartEx.""" + + def __init__(self, chartSpace, chartex_part): + super(ChartExWorkbook, self).__init__() + self._chartSpace = chartSpace + self._chartex_part = chartex_part + + def update_from_xlsx_blob(self, xlsx_blob): + """ + Replace the Excel spreadsheet in the related |EmbeddedXlsxPart| with + the Excel binary in *xlsx_blob*, adding a new |EmbeddedXlsxPart| if + there isn't one. + """ + xlsx_part = self.xlsx_part + if xlsx_part is None: + self.xlsx_part = EmbeddedXlsxPart.new(xlsx_blob, self._chartex_part.package) + return + xlsx_part.blob = xlsx_blob + + @property + def xlsx_part(self): + """Optional |EmbeddedXlsxPart| object containing data for this chart extension. + + This related part has its rId at `cx:chartSpace/cx:chartData/cx:externalData/@r:id`. + This value is |None| if there is no `` element. + """ + + xlsx_part_rId = self._chartSpace.xlsx_part_rId + return None if xlsx_part_rId is None else self._chartex_part.related_part(xlsx_part_rId) + + @xlsx_part.setter + def xlsx_part(self, xlsx_part): + """ + Set the related |EmbeddedXlsxPart| to *xlsx_part*. Assume one does + not already exist. + """ + from pptx.opc.constants import RELATIONSHIP_TYPE as RT + from pptx.oxml.ns import qn + + rId = self._chartex_part.relate_to(xlsx_part, RT.PACKAGE) + # Add externalData element to chartData + externalData = self._chartSpace.get_or_add_externalData() + externalData.set(qn("r:id"), rId) + # Remove any namespaced autoUpdate to avoid duplicate attributes + cx_ns = "http://schemas.microsoft.com/office/drawing/2014/chartex" + namespaced_key = f"{{{cx_ns}}}autoUpdate" + if namespaced_key in externalData.attrib: + del externalData.attrib[namespaced_key] + externalData.set("autoUpdate", "0") + + +_CHART_STYLE_XML = ( + b'\r\n' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' +) + +_CHART_COLOR_STYLE_XML = ( + b'\r\n' + b'' + b'' + b'' + b'' + b"" + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b'' + b"" +) diff --git a/src/pptx/parts/slide.py b/src/pptx/parts/slide.py index fd101cbe6..d5f2d1436 100644 --- a/src/pptx/parts/slide.py +++ b/src/pptx/parts/slide.py @@ -17,6 +17,7 @@ from pptx.oxml.slide import CT_NotesMaster, CT_NotesSlide, CT_Slide, CT_SlideLayout from pptx.oxml.theme import CT_OfficeStyleSheet from pptx.parts.chart import ChartPart +from pptx.parts.chartex import ChartExPart from pptx.parts.comments import CommentsPart, ModernCommentsPart from pptx.parts.embeddedpackage import EmbeddedPackagePart from pptx.parts.image import Image, ImagePart @@ -27,7 +28,7 @@ from pptx.parts.presentation import PresentationPart if TYPE_CHECKING: - from pptx.chart.data import ChartData + from pptx.chart.data import ChartData, WaterfallChartData from pptx.enum.chart import XL_CHART_TYPE from pptx.media import Video from pptx.parts.image import Image, ImagePart @@ -200,6 +201,30 @@ def add_chart_part(self, chart_type: XL_CHART_TYPE, chart_data: ChartData): """ return self.relate_to(ChartPart.new(chart_type, chart_data, self._package), RT.CHART) + def add_chartex_part(self, chart_data: WaterfallChartData) -> str: + """Return str rId of new |ChartExPart| containing a waterfall chart. + + The chart depicts `chart_data` and is related to the slide contained in this + part by the returned `rId`. + """ + chartex_part = ChartExPart.new(self._package) + # populate the series on the chart XML + plotAreaRegion = chartex_part._element.chart.plotArea.plotAreaRegion + plotAreaRegion.add_waterfall_series( + chart_data.series_name or "Series 1", + data_id=0, + subtotal_indices=chart_data.subtotals or None, + ) + # populate chart data dimensions + data_elem = chartex_part._element.chartData.data_lst[0] + data_elem.add_string_dimension("cat", chart_data.categories_ref, chart_data.categories) + data_elem.add_numeric_dimension( + "val", chart_data.values_ref, chart_data.series_values, chart_data.number_format + ) + # embed the Excel workbook + chartex_part.chartex_workbook.update_from_xlsx_blob(chart_data.xlsx_blob) + return self.relate_to(chartex_part, RT.CHARTEX) + def add_embedded_ole_object_part( self, prog_id: PROG_ID | str, ole_object_file: str | IO[bytes] ): diff --git a/src/pptx/shapes/graphfrm.py b/src/pptx/shapes/graphfrm.py index c0ed2bbab..28f60f50e 100644 --- a/src/pptx/shapes/graphfrm.py +++ b/src/pptx/shapes/graphfrm.py @@ -13,6 +13,7 @@ from pptx.shared import ParentedElementProxy from pptx.spec import ( GRAPHIC_DATA_URI_CHART, + GRAPHIC_DATA_URI_CHARTEX, GRAPHIC_DATA_URI_OLEOBJ, GRAPHIC_DATA_URI_TABLE, ) @@ -21,9 +22,11 @@ if TYPE_CHECKING: from pptx.chart.chart import Chart + from pptx.chart.chartex import ChartEx from pptx.dml.effect import ShadowFormat from pptx.oxml.shapes.graphfrm import CT_GraphicalObjectData, CT_GraphicalObjectFrame from pptx.parts.chart import ChartPart + from pptx.parts.chartex import ChartExPart from pptx.parts.slide import BaseSlidePart from pptx.types import ProvidesPart @@ -48,6 +51,16 @@ def chart(self) -> Chart: raise ValueError("shape does not contain a chart") return self.chart_part.chart + @property + def chartex(self) -> "ChartEx": + """The |ChartEx| object containing the chart extension in this graphic frame. + + Raises |ValueError| if this graphic frame does not contain a chart extension. + """ + if not self.has_chartex: + raise ValueError("shape does not contain a chart extension") + return self.chartex_part.chartex + @property def chart_part(self) -> ChartPart: """The |ChartPart| object containing the chart in this graphic frame.""" @@ -56,6 +69,14 @@ def chart_part(self) -> ChartPart: raise ValueError("this graphic frame does not contain a chart") return cast("ChartPart", self.part.related_part(chart_rId)) + @property + def chartex_part(self) -> "ChartExPart": + """The |ChartExPart| object containing the chart extensions in this graphic frame.""" + chartex_rId = self._graphicFrame.chartex_rId + if chartex_rId is None: + raise ValueError("this graphic frame does not contain a chart extension") + return cast("ChartExPart", self.part.related_part(chartex_rId)) + @property def has_chart(self) -> bool: """|True| if this graphic frame contains a chart object. |False| otherwise. @@ -64,6 +85,15 @@ def has_chart(self) -> bool: """ return self._graphicFrame.graphicData_uri == GRAPHIC_DATA_URI_CHART + @property + def has_chartex(self) -> bool: + """|True| if this graphic frame contains a chart extensions object. + + |False| otherwise. Chart extensions were added in Office 2016 and provide + support for newer chart types such as waterfall, funnel, and sunburst charts. + """ + return self._graphicFrame.graphicData_uri == GRAPHIC_DATA_URI_CHARTEX + @property def has_table(self) -> bool: """|True| if this graphic frame contains a table object, |False| otherwise. diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index ba6af035c..356810941 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -37,7 +37,7 @@ if TYPE_CHECKING: from pptx.chart.chart import Chart - from pptx.chart.data import ChartData + from pptx.chart.data import ChartData, WaterfallChartData from pptx.enum.chart import XL_CHART_TYPE from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE from pptx.oxml.shapes import ShapeElement @@ -346,12 +346,57 @@ def add_chart( Note that a |GraphicFrame| shape object is returned, not the |Chart| object contained in that graphic frame shape. The chart object may be accessed using the :attr:`chart` property of the returned |GraphicFrame| object. + + ChartEx (Office 2016 modern) chart types are dispatched to the `cx:` writer path. + `XL_CHART_TYPE.WATERFALL` is fully supported and expects a + |WaterfallChartData| as `chart_data`. The remaining ChartEx types + (TREEMAP, SUNBURST, FUNNEL, BOX_WHISKER, HISTOGRAM, PARETO) have enum + members and round-trip preservation but no writer yet; calling + `add_chart` with one of them raises |NotImplementedError| (see issue #14). """ + from pptx.enum.chart import XL_CHART_TYPE + + _CHARTEX_WRITABLE = (XL_CHART_TYPE.WATERFALL,) + _CHARTEX_WRITER_DEFERRED = ( + XL_CHART_TYPE.TREEMAP, + XL_CHART_TYPE.SUNBURST, + XL_CHART_TYPE.FUNNEL, + XL_CHART_TYPE.BOX_WHISKER, + XL_CHART_TYPE.HISTOGRAM, + XL_CHART_TYPE.PARETO, + ) + if chart_type in _CHARTEX_WRITABLE: + return self.add_chartex(chart_data, x, y, cx, cy) + if chart_type in _CHARTEX_WRITER_DEFERRED: + raise NotImplementedError( + f"{chart_type} is a ChartEx (Office 2016) type with round-trip " + "preservation but no writer yet; only XL_CHART_TYPE.WATERFALL is " + "currently writable. See https://github.com/MHoroszowski/python-pptx/issues/14" + ) rId = self.part.add_chart_part(chart_type, chart_data) graphicFrame = self._add_chart_graphicFrame(rId, x, y, cx, cy) self._recalculate_extents() return cast("Chart", self._shape_factory(graphicFrame)) + def add_chartex( + self, + chart_data: WaterfallChartData, + x: Length, + y: Length, + cx: Length, + cy: Length, + ) -> GraphicFrame: + """Add a new ChartEx (e.g. waterfall) chart to the slide. + + The chart is positioned at (`x`, `y`), has size (`cx`, `cy`), and depicts + `chart_data`. Returns the |GraphicFrame| shape containing the chart. Access the + |ChartEx| object via the :attr:`chartex` property of the returned graphic frame. + """ + rId = self.part.add_chartex_part(chart_data) + graphicFrame = self._add_chartex_graphicFrame(rId, x, y, cx, cy) + self._recalculate_extents() + return cast("GraphicFrame", self._shape_factory(graphicFrame)) + def add_connector( self, connector_type: MSO_CONNECTOR_TYPE, @@ -538,6 +583,22 @@ def _add_chart_graphicFrame( self._spTree.append(graphicFrame) return graphicFrame + def _add_chartex_graphicFrame( + self, rId: str, x: Length, y: Length, cx: Length, cy: Length + ) -> CT_GraphicalObjectFrame: + """Return new `p:graphicFrame` element appended to this shape tree. + + The `p:graphicFrame` element has the specified position and size and refers to the + ChartEx part identified by `rId`. + """ + shape_id = self._next_shape_id + name = "Chart %d" % (shape_id - 1) + graphicFrame = CT_GraphicalObjectFrame.new_chartex_graphicFrame( + shape_id, name, rId, x, y, cx, cy + ) + self._spTree.append(graphicFrame) + return graphicFrame + def _add_cxnSp( self, connector_type: MSO_CONNECTOR_TYPE, diff --git a/src/pptx/spec.py b/src/pptx/spec.py index e9d3b7d58..f53ca551f 100644 --- a/src/pptx/spec.py +++ b/src/pptx/spec.py @@ -10,6 +10,7 @@ from pptx.enum.shapes import MSO_SHAPE GRAPHIC_DATA_URI_CHART = "http://schemas.openxmlformats.org/drawingml/2006/chart" +GRAPHIC_DATA_URI_CHARTEX = "http://schemas.microsoft.com/office/drawing/2014/chartex" GRAPHIC_DATA_URI_OLEOBJ = "http://schemas.openxmlformats.org/presentationml/2006/ole" GRAPHIC_DATA_URI_TABLE = "http://schemas.openxmlformats.org/drawingml/2006/table" diff --git a/tests/chart/test_chartex.py b/tests/chart/test_chartex.py new file mode 100644 index 000000000..b0eebc2f5 --- /dev/null +++ b/tests/chart/test_chartex.py @@ -0,0 +1,177 @@ +# pyright: reportPrivateUsage=false + +"""Test suite for `pptx.chart.chartex` module and `WaterfallChartData`.""" + +from __future__ import annotations + +import zipfile +from io import BytesIO + +import pytest + +from pptx.chart.data import WaterfallChartData +from pptx.util import Inches + + +class DescribeWaterfallChartData: + """Unit-test suite for `pptx.chart.data.WaterfallChartData`.""" + + def it_can_set_categories(self): + chart_data = WaterfallChartData() + chart_data.categories = ["Q1", "Q2", "Q3", "Total"] + assert chart_data.categories == ["Q1", "Q2", "Q3", "Total"] + + def it_can_add_a_series(self): + chart_data = WaterfallChartData() + chart_data.categories = ["Q1", "Q2", "Total"] + chart_data.add_series("Revenue", [100, 50, 150], subtotals=[2]) + + assert chart_data.series_name == "Revenue" + assert chart_data.series_values == [100, 50, 150] + assert chart_data.subtotals == [2] + + def it_provides_excel_refs(self): + chart_data = WaterfallChartData() + chart_data.categories = ["A", "B", "C"] + chart_data.add_series("Sales", [1, 2, 3]) + + assert chart_data.categories_ref == "Sheet1!$A$2:$A$4" + assert chart_data.values_ref == "Sheet1!$B$2:$B$4" + assert chart_data.series_name_ref == "Sheet1!$B$1" + + def it_can_generate_an_xlsx_blob(self): + chart_data = WaterfallChartData() + chart_data.categories = ["Q1", "Q2"] + chart_data.add_series("Revenue", [100, 200]) + + xlsx_blob = chart_data.xlsx_blob + + assert isinstance(xlsx_blob, bytes) + assert len(xlsx_blob) > 0 + # verify it's a valid zip (xlsx is a zip archive) + zf = zipfile.ZipFile(BytesIO(xlsx_blob)) + assert "xl/worksheets/sheet1.xml" in zf.namelist() + zf.close() + + def it_raises_on_mismatched_categories_and_values(self): + chart_data = WaterfallChartData() + chart_data.categories = ["Q1", "Q2", "Q3"] + chart_data.add_series("Revenue", [100, 200]) + + with pytest.raises(ValueError, match="categories length.*must equal.*series values"): + chart_data.xlsx_blob + + +class DescribeChartEx: + """Unit-test suite for `pptx.chart.chartex.ChartEx`.""" + + def it_applies_subtotals_from_chart_data(self): + """Subtotals set via chart_data.add_series flow through to chart XML.""" + from pptx import Presentation + from pptx.oxml.ns import qn + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + + chart_data = WaterfallChartData() + chart_data.categories = ["Q1", "Q2", "Q3", "Total"] + chart_data.add_series("Revenue", [100, 50, -30, 120], subtotals=[3]) + + graphic_frame = slide.shapes.add_chartex( + chart_data, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + ) + + chartex = graphic_frame.chartex + # verify the subtotal idx=3 appears in the XML + series_el = chartex._element.chart.plotArea.plotAreaRegion.findall(qn("cx:series"))[0] + subtotals = series_el.findall(f".//{qn('cx:subtotals')}/{qn('cx:idx')}") + assert [int(el.get("val")) for el in subtotals] == [3] + + def it_can_replace_data(self): + """End-to-end: build via add_chartex → replace_data → verify.""" + from pptx import Presentation + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank layout + + chart_data = WaterfallChartData() + chart_data.categories = ["Q1", "Q2", "Q3", "Total"] + chart_data.add_series("Revenue", [100, 50, -30, 120], subtotals=[3]) + + graphic_frame = slide.shapes.add_chartex( + chart_data, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + ) + + chartex = graphic_frame.chartex + assert chartex.chart_type == "waterfall" + + series_list = chartex.series + assert len(series_list) == 1 + assert series_list[0].name == "Revenue" + assert series_list[0].values == [100.0, 50.0, -30.0, 120.0] + + # --- now replace the data and verify --- + new_data = WaterfallChartData() + new_data.categories = ["Jan", "Feb", "Mar", "Apr", "Total"] + new_data.add_series("Profit", [200, -50, 100, 75, 325], subtotals=[4]) + + chartex.replace_data(new_data) + + # re-fetch series from the live XML + chartex2 = graphic_frame.chartex + series_list2 = chartex2.series + assert len(series_list2) == 1 + assert series_list2[0].name == "Profit" + assert series_list2[0].values == [200.0, -50.0, 100.0, 75.0, 325.0] + + def it_removes_stale_dataPt_on_replace_data(self): + """replace_data with fewer categories removes out-of-range dataPt elements.""" + from pptx import Presentation + from pptx.oxml.xmlchemy import OxmlElement + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + + chart_data = WaterfallChartData() + chart_data.categories = ["A", "B", "C", "D", "E", "F", "G", "H"] + chart_data.add_series("S", [1, 2, 3, 4, 5, 6, 7, 8], subtotals=[7]) + + graphic_frame = slide.shapes.add_chartex( + chart_data, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + ) + chartex = graphic_frame.chartex + + # --- manually add dataPt elements to the series (simulating template) --- + series_el = chartex._element.chart.plotArea.plotAreaRegion.series_lst[0] + for i in range(8): + dataPt = OxmlElement("cx:dataPt") + dataPt.set("idx", str(i)) + series_el.append(dataPt) + + assert len(series_el.dataPt_lst) == 8 + + # --- replace with fewer categories --- + new_data = WaterfallChartData() + new_data.categories = ["X", "Y", "Z", "Total"] + new_data.add_series("S2", [10, 20, 30, 60], subtotals=[3]) + + chartex.replace_data(new_data) + + remaining = series_el.dataPt_lst + remaining_indices = [int(dp.get("idx")) for dp in remaining] + # only indices 0-3 should remain (4 categories) + assert all(idx < 4 for idx in remaining_indices) + # indices 4-7 should have been removed + assert not any(idx >= 4 for idx in remaining_indices) diff --git a/tests/chart/test_chartex_extended.py b/tests/chart/test_chartex_extended.py new file mode 100644 index 000000000..ea6a7874a --- /dev/null +++ b/tests/chart/test_chartex_extended.py @@ -0,0 +1,320 @@ +"""Fork-specific behavioral tests for ChartEx (`cx:`) modern-chart support. + +Covers the surface added on top of the GetThematic port: the `add_chart` +dispatch shim, the extended `XL_CHART_TYPE` members, round-trip preservation, +content-type / relationship wiring, the `NotImplementedError` for +writer-deferred types, and the `WaterfallChartData` data API. See issue #14. +""" + +from __future__ import annotations + +import io +import zipfile + +import pytest + +from pptx import Presentation +from pptx.chart.data import WaterfallChartData +from pptx.enum.chart import XL_CHART_TYPE +from pptx.util import Inches + +_CHARTEX_TYPES = [ + "WATERFALL", + "TREEMAP", + "SUNBURST", + "FUNNEL", + "BOX_WHISKER", + "HISTOGRAM", + "PARETO", +] + + +def _slide(): + prs = Presentation() + return prs, prs.slides.add_slide(prs.slide_layouts[5]) + + +def _waterfall_data(): + cd = WaterfallChartData() + cd.categories = ["Q1", "Q2", "Q3", "Q4", "Total"] + cd.add_series("Revenue", [100, 50, -30, 80, 200], subtotals=[4]) + return cd + + +def _save_reopen(prs): + buf = io.BytesIO() + prs.save(buf) + blob = buf.getvalue() + return blob, Presentation(io.BytesIO(blob)) + + +class DescribeXlChartTypeChartExMembers: + @pytest.mark.parametrize("name", _CHARTEX_TYPES) + def it_exposes_each_chartex_member(self, name): + assert hasattr(XL_CHART_TYPE, name) + + @pytest.mark.parametrize("name", _CHARTEX_TYPES) + def it_assigns_unique_values_to_chartex_members(self, name): + member = getattr(XL_CHART_TYPE, name) + clashes = [m for m in XL_CHART_TYPE if m is not member and int(m) == int(member)] + assert clashes == [] + + def it_places_chartex_values_outside_the_ms_range(self): + for name in _CHARTEX_TYPES: + assert int(getattr(XL_CHART_TYPE, name)) >= 1000 + + +class DescribeWaterfallChartData: + def it_round_trips_categories(self): + cd = WaterfallChartData() + cd.categories = ["a", "b", "c"] + assert cd.categories == ["a", "b", "c"] + + def it_records_series_name_values_and_subtotals(self): + cd = WaterfallChartData() + cd.add_series("S", [1, 2, 3], subtotals=[2]) + assert cd.series_name == "S" + assert cd.series_values == [1, 2, 3] + assert cd.subtotals == [2] + + def it_defaults_subtotals_to_empty_list(self): + cd = WaterfallChartData() + cd.add_series("S", [1, 2]) + assert cd.subtotals == [] + + def it_computes_excel_refs_from_category_count(self): + cd = WaterfallChartData() + cd.categories = ["a", "b", "c", "d"] + assert cd.categories_ref == "Sheet1!$A$2:$A$5" + assert cd.values_ref == "Sheet1!$B$2:$B$5" + assert cd.series_name_ref == "Sheet1!$B$1" + + def it_builds_an_xlsx_blob(self): + cd = _waterfall_data() + blob = cd.xlsx_blob + assert blob[:2] == b"PK" # zip magic + assert zipfile.ZipFile(io.BytesIO(blob)).namelist() # valid archive + + def it_raises_when_categories_and_values_mismatch(self): + cd = WaterfallChartData() + cd.categories = ["a", "b"] + cd.add_series("S", [1]) + with pytest.raises(ValueError, match="must equal"): + _ = cd.xlsx_blob + + +class DescribeAddChartDispatch: + def it_dispatches_WATERFALL_to_the_chartex_path(self): + _, slide = _slide() + gf = slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + assert gf.has_chartex is True + assert gf.has_chart is False + + def it_keeps_classic_charts_on_the_c_path(self): + from pptx.chart.data import CategoryChartData + + _, slide = _slide() + cd = CategoryChartData() + cd.categories = ["a", "b"] + cd.add_series("S", (1, 2)) + gf = slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(1), + Inches(1), + Inches(5), + Inches(3), + cd, + ) + assert gf.has_chart is True + assert gf.has_chartex is False + + @pytest.mark.parametrize( + "name", ["TREEMAP", "SUNBURST", "FUNNEL", "BOX_WHISKER", "HISTOGRAM", "PARETO"] + ) + def it_raises_NotImplementedError_for_writer_deferred_types(self, name): + _, slide = _slide() + with pytest.raises(NotImplementedError, match="no writer yet"): + slide.shapes.add_chart( + getattr(XL_CHART_TYPE, name), + Inches(1), + Inches(1), + Inches(5), + Inches(3), + _waterfall_data(), + ) + + def it_can_add_via_add_chartex_directly(self): + _, slide = _slide() + gf = slide.shapes.add_chartex(_waterfall_data(), Inches(1), Inches(1), Inches(6), Inches(4)) + assert gf.has_chartex is True + + def it_leaves_the_package_unmutated_when_a_deferred_type_raises(self): + # Atomicity: NotImplementedError must fire before any part/rel is + # created, so a caught error does not leave a corrupt presentation. + prs, slide = _slide() + shape_count_before = len(slide.shapes._spTree) + part_count_before = len(list(prs.part.package.iter_parts())) + with pytest.raises(NotImplementedError): + slide.shapes.add_chart( + XL_CHART_TYPE.SUNBURST, + Inches(1), + Inches(1), + Inches(5), + Inches(3), + _waterfall_data(), + ) + assert len(slide.shapes._spTree) == shape_count_before + assert len(list(prs.part.package.iter_parts())) == part_count_before + blob, _ = _save_reopen(prs) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + assert not any("chartEx" in n for n in names) + + +class DescribeChartExRoundTrip: + def it_writes_a_chartex_part_into_the_package(self): + prs, slide = _slide() + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + blob, _ = _save_reopen(prs) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + assert any("chartEx" in n and n.endswith(".xml") for n in names) + + def it_declares_the_chartex_content_type(self): + prs, slide = _slide() + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + blob, _ = _save_reopen(prs) + ct = zipfile.ZipFile(io.BytesIO(blob)).read("[Content_Types].xml").decode() + assert "chartex+xml" in ct + + def it_relates_the_slide_to_the_chartex_part(self): + prs, slide = _slide() + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + blob, _ = _save_reopen(prs) + z = zipfile.ZipFile(io.BytesIO(blob)) + rels = z.read("ppt/slides/_rels/slide1.xml.rels").decode() + assert "chartEx" in rels + + def it_preserves_the_chartex_part_when_unrelated_slide_changes(self): + prs, slide = _slide() + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + blob1, prs2 = _save_reopen(prs) + before = {n for n in zipfile.ZipFile(io.BytesIO(blob1)).namelist() if "chartEx" in n} + prs2.slides.add_slide(prs2.slide_layouts[6]) + blob2, _ = _save_reopen(prs2) + after = {n for n in zipfile.ZipFile(io.BytesIO(blob2)).namelist() if "chartEx" in n} + assert before + assert before == after + + def it_reads_back_has_chartex_on_the_reopened_shape(self): + prs, slide = _slide() + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + _, prs2 = _save_reopen(prs) + shapes = [s for s in prs2.slides[0].shapes if getattr(s, "has_chartex", False)] + assert len(shapes) == 1 + + def it_exposes_a_chartex_proxy_via_the_graphic_frame(self): + prs, slide = _slide() + gf = slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + assert gf.chartex is not None + + def it_emits_a_waterfall_series_layout(self): + prs, slide = _slide() + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(1), + Inches(1), + Inches(6), + Inches(4), + _waterfall_data(), + ) + blob, _ = _save_reopen(prs) + z = zipfile.ZipFile(io.BytesIO(blob)) + cx = [ + n for n in z.namelist() if "chartEx" in n and n.endswith(".xml") and "_rels" not in n + ][0] + assert "waterfall" in z.read(cx).decode() + + def it_keeps_a_classic_and_a_chartex_chart_together(self): + from pptx.chart.data import CategoryChartData + + prs, slide = _slide() + cd = CategoryChartData() + cd.categories = ["a", "b"] + cd.add_series("S", (1, 2)) + slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(1), + Inches(1), + Inches(4), + Inches(3), + cd, + ) + slide.shapes.add_chart( + XL_CHART_TYPE.WATERFALL, + Inches(5), + Inches(1), + Inches(4), + Inches(3), + _waterfall_data(), + ) + blob, _ = _save_reopen(prs) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + has_c = any("charts/chart1.xml" in n for n in names) + has_cx = any("chartEx" in n and n.endswith(".xml") for n in names) + assert has_c + assert has_cx + + def it_does_not_inject_a_chartex_part_into_a_plain_deck(self): + prs = Presentation() + prs.slides.add_slide(prs.slide_layouts[6]) + blob, _ = _save_reopen(prs) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + assert not any("chartEx" in n for n in names) diff --git a/tests/oxml/shapes/test_groupshape.py b/tests/oxml/shapes/test_groupshape.py index 3db308b95..75752d550 100644 --- a/tests/oxml/shapes/test_groupshape.py +++ b/tests/oxml/shapes/test_groupshape.py @@ -4,6 +4,8 @@ import pytest +from pptx.oxml import parse_xml +from pptx.oxml.ns import nsdecls from pptx.oxml.shapes.autoshape import CT_Shape from pptx.oxml.shapes.graphfrm import CT_GraphicalObjectFrame from pptx.oxml.shapes.groupshape import CT_GroupShape @@ -123,6 +125,40 @@ def but_it_ignores_sibling_ids_outside_the_shape_tree(self): # ---and `_next_shape_id` (max + 1) stays in the safe shape-id range--- assert spTree.max_shape_id + 1 == 3 + def it_yields_shapes_wrapped_in_mc_AlternateContent(self): + spTree = parse_xml( + "" + " " + ' ' + " " + " " + " " + " " + ' ' + " " + " " + ' ' + ' ' + " " + " ' + " " + " " + " " + ' ' + " " + " " + " " + "" % nsdecls("a", "mc", "p", "r") + ) + + shape_elms = list(spTree.iter_shape_elms()) + + assert len(shape_elms) == 2 + assert shape_elms[0].tag.endswith("}sp") + assert shape_elms[1].tag.endswith("}graphicFrame") + # fixtures --------------------------------------------- @pytest.fixture diff --git a/tests/parts/test_chartex.py b/tests/parts/test_chartex.py new file mode 100644 index 000000000..46740f124 --- /dev/null +++ b/tests/parts/test_chartex.py @@ -0,0 +1,115 @@ +"""Unit-test suite for `pptx.parts.chartex` module.""" + +from __future__ import annotations + +from pptx.opc.constants import CONTENT_TYPE as CT +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.opc.package import OpcPackage +from pptx.opc.packuri import PackURI +from pptx.oxml.chart.chartex import CT_ChartSpace +from pptx.parts.chartex import ( + _CHART_COLOR_STYLE_XML, + _CHART_STYLE_XML, + ChartColorStylePart, + ChartExPart, + ChartStylePart, +) + +from ..unitutil.mock import instance_mock, method_mock + + +class DescribeChartExPart: + """Unit-test suite for `pptx.parts.chartex.ChartExPart` objects.""" + + def it_can_construct_a_new_chartex_part(self, request): + package_ = instance_mock(request, OpcPackage) + package_.next_partname.return_value = PackURI("/ppt/charts/chartEx42.xml") + chartex_part_ = instance_mock(request, ChartExPart, spec_set=False) + load_ = method_mock( + request, ChartExPart, "load", autospec=False, return_value=chartex_part_ + ) + ct_chartspace_ = instance_mock(request, CT_ChartSpace) + CT_ChartSpace_new_ = method_mock( + request, CT_ChartSpace, "new", autospec=False, return_value=ct_chartspace_ + ) + style_part_ = instance_mock(request, ChartStylePart) + ChartStylePart_new_ = method_mock( + request, ChartStylePart, "new", autospec=False, return_value=style_part_ + ) + color_style_part_ = instance_mock(request, ChartColorStylePart) + ChartColorStylePart_new_ = method_mock( + request, + ChartColorStylePart, + "new", + autospec=False, + return_value=color_style_part_, + ) + + chartex_part = ChartExPart.new(package_) + + package_.next_partname.assert_called_once_with(ChartExPart.partname_template) + load_.assert_called_once_with( + "/ppt/charts/chartEx42.xml", + CT.OFC_CHART_EX, + package_, + b"", + ) + CT_ChartSpace_new_.assert_called_once_with() + assert chartex_part_._element is ct_chartspace_ + ChartStylePart_new_.assert_called_once_with(package_) + chartex_part_.relate_to.assert_any_call(style_part_, RT.CHART_STYLE) + ChartColorStylePart_new_.assert_called_once_with(package_) + chartex_part_.relate_to.assert_any_call(color_style_part_, RT.CHART_COLOR_STYLE) + assert chartex_part is chartex_part_ + + +class DescribeChartStylePart: + """Unit-test suite for `pptx.parts.chartex.ChartStylePart` objects.""" + + def it_can_construct_a_new_chart_style_part(self, request): + package_ = instance_mock(request, OpcPackage) + package_.next_partname.return_value = PackURI("/ppt/charts/style42.xml") + style_part_ = instance_mock(request, ChartStylePart) + load_ = method_mock( + request, ChartStylePart, "load", autospec=False, return_value=style_part_ + ) + + style_part = ChartStylePart.new(package_) + + package_.next_partname.assert_called_once_with(ChartStylePart.partname_template) + load_.assert_called_once_with( + "/ppt/charts/style42.xml", + CT.OFC_CHART_STYLE, + package_, + _CHART_STYLE_XML, + ) + assert style_part is style_part_ + + +class DescribeChartColorStylePart: + """Unit-test suite for `pptx.parts.chartex.ChartColorStylePart` objects.""" + + def it_can_construct_a_new_chart_color_style_part(self, request): + package_ = instance_mock(request, OpcPackage) + package_.next_partname.return_value = PackURI("/ppt/charts/colors42.xml") + color_style_part_ = instance_mock(request, ChartColorStylePart) + load_ = method_mock( + request, + ChartColorStylePart, + "load", + autospec=False, + return_value=color_style_part_, + ) + + color_style_part = ChartColorStylePart.new(package_) + + package_.next_partname.assert_called_once_with(ChartColorStylePart.partname_template) + load_.assert_called_once_with( + "/ppt/charts/colors42.xml", + CT.OFC_CHART_COLORS, + package_, + _CHART_COLOR_STYLE_XML, + ) + assert color_style_part is color_style_part_