scanny / python-pptx

Create Open XML PowerPoint documents in Python
MIT License
2.36k stars 511 forks source link

border for data labels #716

Open diego-EA opened 3 years ago

diego-EA commented 3 years ago

I have a bar chart and I need to put a line border around the data labels. I have read that works for text boxs

    textbox.line.color.rgb = RGBColor(0x00, 0x16, 0xBC)

but I don't know if there is anyway to achieve that for data labels. I can reach them

   shape.chart.plots[0].series[0].points[0].data_label.textframe

but I don't find a way of placing there a border.

scanny commented 3 years ago

Can you do it in PowerPoint? If PowerPoint doesn't support it, it is unlikely python-pptx will ever be able to.

diego-EA commented 3 years ago

Yes, in PowerPoint it's possible to format each data label individually (including presence and format of border).

scanny commented 3 years ago

Okay, then the XML must support it. There's no API support for that in python-pptx so you'd have to manipulate the XML there directly. If you're willing to dig into that then the best place to start is probably having a look at the before an after XML:

prs = Presentation("deck-with-bar-chart-with-no-data-label-borders.pptx")
print("before XML == %s" % prs. ... .points[0].data_label._ser.xml)
prs = Presentation("deck-with-bar-chart-with-data-label-borders-added-by-hand.pptx")
print("after XML == %s" % prs. ... .points[0].data_label._ser.xml)

That should show the difference. Make the bar char small, like three bars so the XML doesn't scroll off the page.

diego-EA commented 3 years ago

Many thanks for the advise. Both XML outputs are different (I attach the differences in the image below). How can I change those XML properties in the data labels with no border? I have serialized (pickle) the output of data_label._ser.xml and then I tried to apply it to the labels with no borders;

  my_border = pickle.load(open("C:\...\data_label_border.p", "rb"))
  shape.chart.plots[0].series[0].points[0].data_label._ser.xml = my_border

But I got the following error:

  AttributeError: can't set attribute

How could I get advantage of those XML-differences to achieve my purpose?

data_label_border

scanny commented 3 years ago

I can't read the XML in that format. Just paste it in here as a properly indented code block along with the line of code you used to print it and I can help you find the element you need to work on.

diego-EA commented 3 years ago

Sure, here is the section for the shape with borders in the chart labels:

    <c:dLbls>
      <c:dLbl>
        <c:idx val="0"/>
        <c:spPr>
          <a:noFill/>
          <a:ln w="28575">
            <a:solidFill>
              <a:srgbClr val="671A3D"/>
            </a:solidFill>
          </a:ln>
          <a:effectLst/>
        </c:spPr>
        <c:txPr>
          <a:bodyPr rot="0" spcFirstLastPara="1" vertOverflow="ellipsis" vert="horz" wrap="square" lIns="38100" tIns="19050" rIns="38100" bIns="19050" anchor="ctr" anchorCtr="1">
            <a:spAutoFit/>
          </a:bodyPr>
          <a:lstStyle/>
          <a:p>
            <a:pPr>
              <a:defRPr sz="1197" b="0" i="0" u="none" strike="noStrike" kern="1200" baseline="0">
                <a:solidFill>
                  <a:schemeClr val="tx1">
                    <a:lumMod val="75000"/>
                    <a:lumOff val="25000"/>
                  </a:schemeClr>
                </a:solidFill>
                <a:latin typeface="+mn-lt"/>
                <a:ea typeface="+mn-ea"/>
                <a:cs typeface="+mn-cs"/>
              </a:defRPr>
            </a:pPr>
            <a:endParaRPr lang="de-DE"/>
          </a:p>
        </c:txPr>
        <c:showLegendKey val="0"/>
        <c:showVal val="1"/>
        <c:showCatName val="0"/>
        <c:showSerName val="0"/>
        <c:showPercent val="0"/>
        <c:showBubbleSize val="0"/>
        <c:extLst>
          <c:ext xmlns:c16="http://schemas.microsoft.com/office/drawing/2014/chart" uri="{C3380CC4-5D6E-409C-BE32-E72D297353CC}">
            <c16:uniqueId val="{00000000-4FFA-4F1E-80B2-0345540D9C12}"/>
          </c:ext>
        </c:extLst>
      </c:dLbl>
      <c:dLbl>
        <c:idx val="1"/>
        <c:spPr>
          <a:noFill/>
          <a:ln w="28575">
            <a:solidFill>
              <a:srgbClr val="671A3D"/>
            </a:solidFill>
          </a:ln>

And here is the same section for the shape without borders in the chart labels:

  <c:dLbls>
      <c:dLbl>
        <c:idx val="1"/>
        <c:spPr>
          <a:noFill/>
          <a:ln w="28575">
            <a:noFill/>
          </a:ln>
scanny commented 3 years ago

Okay, so this might be pretty easy then:

dLbl = point.data_label._dLbl
line = pptx.dml.line.LineFormat(dLbl.spPr)
line.color.rgb = RGBColor(0xFF, 0x00, 0x00)

It looks like the spPr element is removed if you set the label to a custom string, so you might need dLbl.get_or_add_spPr() in the second line instead of dLbl.spPr if for some reason it complains about not having one.

diego-EA commented 3 years ago

I have just tried those lines but the second one did not work for me: dLbl is a NoneType object and it has neither attribute spPr nor method get_or_add_spPr().

The following lines worked but they placed the border around the bar and not around the label:

  dl = point.data_label
  line = pptx.dml.line.LineFormat(dl._element.get_or_add_spPr())
  line.color.rgb = RGBColor(0, 0, 255)
diego-EA commented 3 years ago

Sorry, I ignored the 'if you set the label to a custom string' part of your comment. When I take that into account, your solution works perfectly (I finally got the border around the label). Many thanks. I paste here my final code to get the border around the first label of the chart:

tf = shape.chart.plots[0].series[0].points[0].data_label.text_frame
tf.word_wrap = True
tf.text = str(shape.chart.series[0].values[0])
dLbl = shape.chart.plots[0].series[0].points[0].data_label._dLbl
line = pptx.dml.line.LineFormat(dLbl.get_or_add_spPr())
line.color.rgb = RGBColor(0, 0, 255)
scanny commented 3 years ago

Okay, so I think your code is equivalent to this function called with parameters (0, 0):

def box_data_label(series_idx, point_idx):
    """Draw box around value in data-label for point at `point_idx` in `series_idx`."""
    series = shape.chart.series[series_idx]
    data_label = series.points[point_idx].data_label

    # --- set data-label text to numeric value of its point ---
    point_value = series.values[point_idx]
    data_label.text_frame.text = str(point_value)
    data_label.text_frame.word_wrap = True

    # --- display a border (box) around the data-label perimeter ---
    line = pptx.dml.line.LineFormat(data_label._dLbl.get_or_add_spPr())
    line.color.rgb = RGBColor(0, 0, 255)

Why does the text of the data label need to be set? I vaguely remember that if you set anything on a data-label it becomes a custom data label and then you have to set everything for that data-label. Is that why? What do you get if comment out the middle three lines and don't set the text or word-wrap? Does it just show an empty box then?

diego-EA commented 3 years ago

If I don't set the word_wrap of the text frame to True I get the problems mentioned here (dLbl is a NoneType object): https://github.com/scanny/python-pptx/issues/716#issuecomment-880473764

And if I set the word_wrap of the text frame to True I need to set everything for that data label, including it's value (otherwise the data label doesn't show any value).

scanny commented 3 years ago

Ah, okay, so it's the "if you want to set any of it you have to set all of it" characteristic. You don't have to set .word_wrap per se, that's optional, but you do have to set .text, or .has_text_frame = True, or .position otherwise the dLbl element won't exist yet (is a NoneType object). The dLbl element isn't created until the first time you set one of those three.

It might be worth a try to set .position to one of these values without setting the text and see if you can then get a box around the data-label without having to make the text "static". An unfortunate consequence of setting custom text is that it doesn't automatically update if you change the chart values, like do a chart.replace_data() the next month or whatever. That's something to watch out for. Once you've set the text you'll need to explicitly make any updates as they become necessary.

diego-EA commented 3 years ago

You are right: my data labels are 'static' (if I update chart values, labels remain immutable). I tried both suggestion to avoid .word_wrap and here are the results:

scanny commented 3 years ago

Okay, @diego-EA that's really good to know. So I think the short answer to your original question is this::

def box_data_label(chart, series_idx, point_idx):
    """Draw box around value in data-label for point at `point_idx` in `series_idx`."""
    series = chart.series[series_idx]
    data_label = series.points[point_idx].data_label

    # --- set position of data-label as trick to create `dLbl` element for it ---
    data_label.position = XL_LABEL_POSITION.OUTSIDE_END

    # --- display a border (box) around the data-label perimeter ---
    line = pptx.dml.line.LineFormat(data_label._dLbl.get_or_add_spPr())
    line.color.rgb = RGBColor(0, 0, 255)

It's a pretty big advantage to be able to retain the auto-updating behavior, so this will be good to know for others who find this on search.

diego-EA commented 3 years ago

Exactly, that's the answer to my original question. Thank you a lot!

vba34520 commented 2 years ago

Install

pip install python-pptx -U

Code

from pptx.util import Inches
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_DATA_LABEL_POSITION

prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
shapes = slide.shapes
shapes.title.text = ' '

chart_data = CategoryChartData()
chart_data.categories = ['East', 'West', 'Midwest']
chart_data.add_series('Series 1', (19.2, 21.4, 16.7))
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

from pptx.dml.color import RGBColor
from pptx.dml.line import LineFormat

series = chart.series[0]
for point in series.points:
    data_label = point.data_label
    data_label.position = XL_DATA_LABEL_POSITION.OUTSIDE_END
    line = LineFormat(data_label._dLbl.get_or_add_spPr())
    line.color.rgb = RGBColor(255, 0, 0)

prs.save('test.pptx')