scanny / python-pptx

Create Open XML PowerPoint documents in Python
MIT License
2.37k stars 513 forks source link

feature: Point.invertIfNegative #776

Open gergness opened 2 years ago

gergness commented 2 years ago

It is currently possible to set invertIfNegative property on a BarSeries, but when the fill colors for the data points within that bar series have been overridden, PowerPoint does not respect the series level property, it instead expects each data point to have its own invertIfNegative.

I don't think it is currently possible to set this property on a data point without digging into python-pptx's internal XML representation. Would it be possible to add a property on Point (or possibly make a subclass of Point for only points belonging to a BarSeries) that allows setting invertIfNegative?

Here's code to reproduce, an ugly work around, and below are the pptx files and images of them rendering in powerpoint.

from copy import deepcopy

from pptx import Presentation

from pptx.chart.data import CategoryChartData
from pptx.chart.series import BarSeries
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches

# create presentation with 1 slide ------
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])

# define chart data ---------------------
chart_data = CategoryChartData()
chart_data.categories = ['apple', 'banana', 'carrot']
chart_data.add_series('Series 1', (5.2, -2.1, -25))

# add chart to slide --------------------
x, y, cx, cy = Inches(2), Inches(2), Inches(6), Inches(4.5)
graphic_frame = slide.shapes.add_chart(
    XL_CHART_TYPE.COLUMN_CLUSTERED, x, y, cx, cy, chart_data
)
chart = graphic_frame.chart

# ---Set invert_if_negative=False for bar charts---
for ser in chart.series:
    ser.invert_if_negative = False

# ---But also set the colors for each bar
points = chart.series[0].points
for idx, rgb in enumerate(("FF0000", "00FF00", "0000FF")):
    fill = points[idx].format.fill
    fill.solid()
    fill.fore_color.rgb = RGBColor.from_string(rgb)

prs.save('ignores-invertIfNegative.pptx')

# --- A fix is to dig into the pptx internals and copy the invertIfNegative 
# --- to each data point
for idx in range(3):
    invertIfNegative = deepcopy(points._element.get_or_add_invertIfNegative())
    dPt = points[idx]._ser.get_or_add_dPt_for_point(idx)
    dPt.append(invertIfNegative)

prs.save('fixed.pptx')

ignores-invertIfNegative.pptx

Screen Shot 2021-12-17 at 11 09 05 AM

fixed.pptx

Screen Shot 2021-12-17 at 11 08 13 AM
scanny commented 2 years ago

A slightly less ugly and more robust method would be the following:

for point in chart.series[0].points:
    point_invert_if_negative(point, False)

def point_set_invert_if_negative(point: Point, value: bool):
    """Set the invert-if-negative attribute for `point` to `value`."""

    def get_or_add_invertIfNegative(dPt):
        invertIfNegatives = dPt.xpath("./c:invertIfNegative")
        if invertIfNegatives:
            return invertIfNegatives[0]
        invertIfNegative = OxmlElement("c:invertIfNegative")
        dPt.insert_element_before(
            invertIfNegative,
            (
                "c:marker",
                "c:bubble3D",
                "c:explosion",
                "c:spPr",
                "c:pictureOptions",
                "c:extLst",
            )
        )

    dPt = point._ser.get_or_add_dPt_for_point(self._idx)
    invertIfNegative = get_or_add_invertIfNegative(dPt)
    invertIfNegative.set("val", 1 if value else 0)

Less ugly here meaning you can hide the point_set_invert_if_negative() function somewhere you don't have to look at it and the calling code get quite simple.

This implementation (not tested by the way) is essentially what would be happening in python-pptx if this feature was implemented, just the call would become point.invert_if_negative = False in that case.

The main robustness difference here is that the <c:invertIfNegative> element is inserted in the right sequence among the eight possible children of <c:dPt>. Typically that matters and might come up to bite later on after a seemingly unrelated change if not attended to here.

If you want to give this a try I'm happy to help with any debugging that might be necessary.

A fuller solution (actually buiding this in) would likely require sponsorship. I'd say not to exceed two days, likely only one.

gergness commented 2 years ago

Awesome, thanks so much for the quick response and the tip about order mattering.

I think we're okay with this hack for now, but I definitely owe you (another) coffee or beer! Hope you're doing well!

For posterity, here are a few edits to that function to get it working.

from pptx import Presentation

from pptx.chart.data import CategoryChartData
from pptx.chart.series import BarSeries
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Inches

def point_set_invert_if_negative(point, value):
    """Set the invert-if-negative attribute for `point` to `value`."""
    def get_or_add_invertIfNegative(dPt):
        invertIfNegatives = dPt.xpath("./c:invertIfNegative")
        if invertIfNegatives:
            return invertIfNegatives[0]
        invertIfNegative = OxmlElement("c:invertIfNegative")
        dPt.insert_element_before(
            invertIfNegative,
            *(
                "c:marker",
                "c:bubble3D",
                "c:explosion",
                "c:spPr",
                "c:pictureOptions",
                "c:extLst",
            )
        )
        return invertIfNegative

    dPt = point._ser.get_or_add_dPt_for_point(point._idx)
    invertIfNegative = get_or_add_invertIfNegative(dPt)
    invertIfNegative.set("val", "1" if value else "0")

# create presentation with 1 slide ------
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])

# define chart data ---------------------
chart_data = CategoryChartData()
chart_data.categories = ['apple', 'banana', 'carrot']
chart_data.add_series('Series 1', (5.2, -2.1, -25))

# add chart to slide --------------------
x, y, cx, cy = Inches(2), Inches(2), Inches(6), Inches(4.5)
graphic_frame = slide.shapes.add_chart(
    XL_CHART_TYPE.COLUMN_CLUSTERED, x, y, cx, cy, chart_data
)
chart = graphic_frame.chart

# ---Set invert_if_negative=False for bar charts---
for ser in chart.series:
    ser.invert_if_negative = False

# ---But also set the colors for each bar
points = chart.series[0].points
for idx, rgb in enumerate(("FF0000", "00FF00", "0000FF")):
    if rgb is None:
        continue
    fill = points[idx].format.fill
    fill.solid()
    fill.fore_color.rgb = RGBColor.from_string(rgb)
    # --- Also set the invertIfNegative on the points
    point_set_invert_if_negative(points[idx], False)

prs.save('fixed-scanny.pptx')
scanny commented 2 years ago

Glad to hear you got it working and thanks very much for the working code, that will help others in future I'm sure :)

jjaureguiberry commented 2 years ago

Greetings, I was going through the same path when trying to solve this issue, but looking into python-pptx internals (amazing BTW) I tried this locally and it works for my particular scenario. Would this change break other charts?


index 2974a226..94c99612 100644
--- a/pptx/oxml/chart/series.py
+++ b/pptx/oxml/chart/series.py
@@ -54,6 +54,7 @@ class CT_DPt(BaseOxmlElement):
         "c:extLst",
     )
     idx = OneAndOnlyOne("c:idx")
+    invertIfNegative = ZeroOrOne("c:invertIfNegative", successors=_tag_seq[1:])
     marker = ZeroOrOne("c:marker", successors=_tag_seq[3:])
     spPr = ZeroOrOne("c:spPr", successors=_tag_seq[6:])
     del _tag_seq```