Add Bullet list using python-pptx - python

I am using the python-pptx library for pptx manipulation. I want to add a bullet list in the pptx document.
I am using the following snippet to add list item:
p = text_frame.add_paragraph()
run = p.add_run()
p.level = 0
run.text = "First"
But it does not display bullet points; please guide.

It is currently not possible to access the bullet property using python-pptx, but I want to share a workaround that has served me well.
This requires the use of a pptx template, in which we exploit the fact that the levels in a slide layout can be customized individually.
For instance, in the slide layout you could set level 0 to be normal text, level 1 to be bullets, and level 2 to be numbers or any other list style you want. You can then modify font size, indentation (using the ruler at the top), and any other property of each level to get the look you want.
For my use-case, I just set levels 1 and 2 to have the same indentation and size as level 0, making it possible to create bullet lists and numbered lists by simply setting the level to the corresponding value.
This is how my slide layout looks in the template file:
slide layout example
And this is how I set the corresponding list style in the code:
p.level = 0 # Regular text
p.level = 1 # Bullet
p.level = 2 # Numbers
In theory, you should be able to set it up exactly the way you want, even with indented sub-lists and so on. The only limitation I am aware of is that there seems to be a maximum of 8 levels that can be customized in the slide layout.

My solution:
from pptx.oxml.xmlchemy import OxmlElement
def SubElement(parent, tagname, **kwargs):
element = OxmlElement(tagname)
element.attrib.update(kwargs)
parent.append(element)
return element
def makeParaBulletPointed(para):
"""Bullets are set to Arial,
actual text can be a different font"""
pPr = para._p.get_or_add_pPr()
## Set marL and indent attributes
pPr.set('marL','171450')
pPr.set('indent','171450')
## Add buFont
_ = SubElement(parent=pPr,
tagname="a:buFont",
typeface="Arial",
panose="020B0604020202020204",
pitchFamily="34",
charset="0"
)
## Add buChar
_ = SubElement(parent=pPr,
tagname='a:buChar',
char="•")

This question is still up to date on May 27, 2021.
Following up on #OD1995's answer I would like to add a little more detail as well as my turn on the problem.
I created a new package with the following code:
from pptx.oxml.xmlchemy import OxmlElement
def getBulletInfo(paragraph, run=None):
"""Returns the attributes of the given <a:pPr> OxmlElement
as well as its runs font-size.
*param: paragraph* pptx _paragraph object
*param: run* [optional] specific _run object
"""
pPr = paragraph._p.get_or_add_pPr()
if run is None:
run = paragraph.runs[0]
p_info = {
"marL": pPr.attrib['marL'],
"indent": pPr.attrib['indent'],
"level": paragraph.level,
"fontName": run.font.name,
"fontSize": run.font.size,
}
return p_info
def SubElement(parent, tagname, **kwargs):
"""Helper for Paragraph bullet Point
"""
element = OxmlElement(tagname)
element.attrib.update(kwargs)
parent.append(element)
return element
def pBullet(
paragraph, # paragraph object
font, # fontName of that needs to be applied to bullet
marL='864000',
indent='-322920',
size='350000' # fontSize (in )
):
"""Bullets are set to Arial,
actual text can be a different font
"""
pPr = paragraph._p.get_or_add_pPr()
# Set marL and indent attributes
# Indent is the space between the bullet and the text.
pPr.set('marL', marL)
pPr.set('indent', indent)
# Add buFont
_ = SubElement(parent=pPr,
tagname="a:buSzPct",
val="350000"
)
_ = SubElement(parent=pPr,
tagname="a:buFont",
typeface=font,
# panose="020B0604020202020204",
# pitchFamily="34",
# charset="0"
)
# Add buChar
_ = SubElement(parent=pPr,
tagname='a:buChar',
char="•"
)
The reason I did this is because I was frustrated that the bullet character was not of the same size as the original and the text was stuck to the bullet.
getBulletInfo() allows me to retrieve information from an existing paragraph.
I use this information to populate the element's attributes (so that it is identical to the template).
Anyways the main add-on is the creation of a sub-element <a:buSzPct> (documentation here and here). This is a size percentage that can go from 25% to 350% (100000 = 100%).

Try this:
p = text_frame.add_paragraph()
p.level = 0
p.text = "First"
Or if the text_frame already has a paragraph:
p = text_frame.paragraphs[0]
p.level = 0
p.text = "First"

Related

Adding text into groups does not work for svgwrite

I'm writing some basic script to create basic charts using python's svgwrite. I have successfully been able to create groups with other items, such as circles, paths and lines. However when adding several text elements into a group those are not properly shown in a group when I open the svg figure with Inkscape. The text shows up all right, but it is just not grouped.
This is my piece of code:
# Create group for constellation names
const_names = dwg.add(dwg.g(id='constellation_names',
stroke='none',
fill=config.constellation_name_font.color.get_hex_rgb(),
fill_opacity=config.constellation_name_font.color.get_float_alpha(),
font_size=config.constellation_name_font.size*pt,
font_family=config.constellation_name_font.font_family))
log.warning("Constellation name groups are not working!")
if config.constellation_name_enable:
w, h = constellation.get_mean_position()
# Add every text item into the group
const_names.add(dwg.text(constellation.name,
insert=(w*pt, h*pt),
)
)
Turns out this was a type-8 error (I had a bug on the code). This is how my code ended up looking like. All text instances are grouped on a single group.
def _add_constellation_names(dwg, constellations, config):
const_names = dwg.add(dwg.g(id='constellation_names',
stroke='none',
fill=config.constellation_name_font.color.get_hex_rgb(),
fill_opacity=config.constellation_name_font.color.get_float_alpha(),
font_size=config.constellation_name_font.size*pt,
font_family=config.constellation_name_font.font_family))
for constellation in constellations:
kwargs = {}
if constellation.custom_color != None:
kwargs["fill"] = constellation.custom_color.get_hex_rgb()
kwargs["fill_opacity"] = constellation.custom_color.get_float_alpha()
w, h = constellation.get_mean_position()
const_names.add(dwg.text(constellation.get_display_name(),
insert=(w*pt, h*pt),
text_anchor="middle",
**kwargs,
)
)

Change start angle/ first slice angle of PIE chart in python-pptx

I have a pie chart with multiple segments in it. I want certain pie segment to start from an angle I provide.
Pie chart "rotation" features in PowerPoint are limited to positioning the "start" angle of the first segment, with segments growing in the clockwise direction only.
So you can specify that the first element appears at 70-degrees (clockwise from the 12-o'clock position).
Currently there is no API support for this, but the value is in the XML at the location mentioned by #Saleh above: /c:chartSpace/c:chart/c:plotArea/c:pieChart/c:firstSliceAng
You can access the c:doughnutChart element on:
chart.plots[0]._element
And print it with:
print(chart.plots[0]._element.xml)
If it happens to already have a c:firstSliceAng element on it, you can just change the setting, perhaps something like this:
pieChart = chart.plots[0]._element
firstSliceAng = pieChart.xpath("./c:firstSliceAng")
firstSliceAng["val"] = "70"
If there is no firstSliceAng element there you need to use lxml calls to add it first.
Below are the steps to change firstSliceAng of piechart in python-pptx:
Check whether firstSliceAng tags exist or not:
firstSliceAng = pieChart.xpath("./c:firstSliceAng")
print(firstSliceAng)
Expected output:
[<some-object>]
If the list is blank, then it means you need to add firstSliceAng using lxml or oxml
To add it with lxml use following steps:
tag = tags[0]
child = OxmlElement('c:firstSliceAng')
# keep in mind that **start_angle** should always be string whose value is int and not float
**start_angle = str(int(75.55))**
child.set('val', start_angle+45)
tag.addprevious(child)
To add it with lxml use following steps:
doc = etree.parse(StringIO(xml))
root = doc.getroot()
# keep in mind that **start_angle** should always be string whose value is int and not float
**start_angle = str(int(75.55))**
c = Element(QName(root.nsmap['c'], 'firstSliceAng'), val=start_angle)
present_element = chart.plots[0]._element.xpath('c:varyColors')[0]
present_element.addprevious(c)```

Python-PPTX : Data Label Positions not working for Doughnut Chart

I have a Chart Placeholder, into which I have inserted a chart of chart_type 'DOUGHNUT'. I've added data labels to it and want to change their positions. For some reason, the method given in the documentation has no effect on my chart.
Here is my code, please help if I'm doing something wrong -
from pptx import Presentation
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LABEL_POSITION, XL_DATA_LABEL_POSITION, XL_TICK_MARK, XL_TICK_LABEL_POSITION
chart_data = ChartData()
chart_data.add_series('', tuple(input_chart_data[x] for x in input_chart_data))
graphic_frame = content_placeholder.insert_chart(XL_CHART_TYPE.DOUGHNUT, chart_data)
chart = graphic_frame.chart
chart.has_legend = False
#Adding Data-Labels with custom text
chart.plots[0].has_data_labels = True
data_labels = chart.plots[0].data_labels
i = 0
series = chart.series[0]
for point in series.points:
fill = point.format.fill
fill.solid()
fill.fore_color.rgb = RGBColor(<color_code>)
point.data_label.has_text_frame = True
#Assigning custom text for data label associated with each data-point
point.data_label.text_frame.text = str(chart_data.categories[i].label) + "\n" + str(float(chart.series[0].values[i])) + "%"
for run in point.data_label.text_frame.paragraphs[0].runs:
run.font.size = Pt(10)
i+=1
data_labels.position = XL_LABEL_POSITION.OUTSIDE_END
PowerPoint is finicky about where you place certain chart attributes and feels free to ignore them when it wants (although it does so consistently).
A quick option worth trying is to set the value individually, point-by-point in the series. So something like:
for point in series.points:
point.data_label.position = XL_LABEL_POSITION.OUTSIDE_END
The most reliable method is to start by producing the effect you want by hand, using PowerPoint itself on an example chart, then inspecting the XML PowerPoint produces in the saved file, perhaps using opc-diag. Once you've identified what XML produces the desired effect (or discovered PowerPoint won't let you do it), then you can proceed to working out how to get the XML generated by python-pptx. That might make a good second question if you're able to get that far.
I made it work by writing the below code.
def apply_data_labels(self, chart):
plot = chart.plots[0]
plot.has_data_labels = True
for series in plot.series:
values = series.values
counter = 0
for point in series.points:
data_label = point.data_label
data_label.has_text_frame = True
data_label.text_frame.text = str(values[counter])
counter = counter + 1
the cause of error is setting the label position. no matter what you set it asks to repair the PPT. will have to drill down more to see why is it so.
Also to save some more time the formatting doesn't works(font color, size)
If anybody has any leads then please help.
To add on Vibhanshu's response, I could get the formatting (font type, font color, size etc) to work using the following code:
for idx, point in enumerate(chart.series[0].points):
# set position
point.data_label.position = XL_LABEL_POSITION.OUTSIDE_END
# set text
point.data_label.has_text_frame = True
point.data_label.text_frame.text = "This is an example"
# set formatting
for paragraph_idx, paragraph in enumerate(point.data_label.text_frame.paragraphs):
paragraph.line_spacing = 0.6 # set paragraph line spacing
for run in paragraph.runs:
run.font.size = Pt(30) #set font size
run.font.name = 'Poppins Medium' #set font name
run.font.color.rgb = RGBColor.from_string("FF0000") #set font color

Reportlab does not reset sequences when creating multiple documents with table of contents

I am using a template function that creates multiple PDF documents in one program execution using reportlab.
These documents are identical by structure and have identical headings. They only differ in content below the headings. All of these documents contain a table of contents element.
I am using sequence tags (<seq/> etc.) to create numbered headings e. g.
1. Top1
1.1 Sub1
2. Top2
2.1 Sub1
2.2 Sub2
This works well for one single document but as soon as I create a second one right after the first the sequences are not reset and the second document's TOC looks like
2. Top1
2.1 Sub1
3. Top2
3.1 Sub1
3.2 Sub2
Creating a third document Top1 will start with 3.
But since I am starting a new document, creating a new BaseDocTemplate class, I was expecting the sequences to be reset. How can I achieve that?
I've tried to create a small as possible example using one of the tutorials for reportlab.
from reportlab.lib.styles import ParagraphStyle as PS
from reportlab.platypus import PageBreak
from reportlab.platypus.paragraph import Paragraph
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from reportlab.platypus.tableofcontents import TableOfContents
from reportlab.platypus.frames import Frame
from reportlab.lib.units import cm
class MyDocTemplate(BaseDocTemplate):
def __init__(self, filename, **kw):
self.allowSplitting = 0
super().__init__(filename, **kw)
template = PageTemplate('normal', [Frame(2.5*cm, 2.5*cm, 15*cm, 25*cm, id='F1')])
self.addPageTemplates(template)
# Entries to the table of contents can be done either manually by
# calling the addEntry method on the TableOfContents object or automatically
# by sending a 'TOCEntry' notification in the afterFlowable method of
# the DocTemplate you are using. The data to be passed to notify is a list
# of three or four items countaining a level number, the entry text, the page
# number and an optional destination key which the entry should point to.
# This list will usually be created in a document template's method like
# afterFlowable(), making notification calls using the notify() method
# with appropriate data.
def afterFlowable(self, flowable):
"Registers TOC entries."
if flowable.__class__.__name__ == 'Paragraph':
text = flowable.getPlainText()
style = flowable.style.name
if style == 'Heading1':
self.notify('TOCEntry', (0, text, self.page))
if style == 'Heading2':
self.notify('TOCEntry', (1, text, self.page))
centered = PS(name = 'centered',
fontSize = 30,
leading = 16,
alignment = 1,
spaceAfter = 20)
h1 = PS(
name = 'Heading1',
fontSize = 14,
leading = 16)
h2 = PS(name = 'Heading2',
fontSize = 12,
leading = 14)
# Heading definition with sequence numbers
heading = {
1 : "<seq id='h1'/>.<seqreset id='h2'/><seqreset id='h3'/> {}",
2 : "<seq id='h1' inc='no'/>.<seq id='h2'/><seqreset id='h3'/> {}",
3 : "<seq id='h1' inc='no'/>.<seq id='h2' inc='no'/>.<seq id='h3'/> {}",
}
def build_document(filename):
# Build story.
story = []
# Create an instance of TableOfContents. Override the level styles (optional)
# and add the object to the story
toc = TableOfContents()
toc.levelStyles = [
PS(fontName='Times-Bold', fontSize=20, name='TOCHeading1', leftIndent=20, firstLineIndent=-20, spaceBefore=10, leading=16),
PS(fontSize=18, name='TOCHeading2', leftIndent=40, firstLineIndent=-20, spaceBefore=5, leading=12),
]
story.append(toc)
story.append(Paragraph('<b>Table of contents</b>', centered))
story.append(PageBreak())
story.append(Paragraph(heading[1].format('First heading'), h1))
story.append(Paragraph('Text in first heading', PS('body')))
story.append(Paragraph(heading[2].format('First sub heading'), h2))
story.append(Paragraph('Text in first sub heading', PS('body')))
story.append(PageBreak())
story.append(Paragraph(heading[2].format('Second sub heading'), h2))
story.append(Paragraph('Text in second sub heading', PS('body')))
story.append(PageBreak())
story.append(Paragraph(heading[2].format('Last heading'), h1))
doc = MyDocTemplate(filename)
doc.multiBuild(story)
if __name__ == "__main__":
build_document("1.pdf")
build_document("2.pdf")
I have found a quick solution which solves my problem but which I don't like as a final solution.
The problem is that I am using global sequences with the same name. h1, h2 and h3 appear in every document. And reportlab doesn't seem to reset them when a new document is started. So instead I reset the manually before filling the story list.
def build_document(filename):
# Build story. story = []
# Reset the sequences
story.append(Paragraph("<seqreset id='h1'/>", PS('body')))
story.append(Paragraph("<seqreset id='h2'/>", PS('body')))
story.append(Paragraph("<seqreset id='h3'/>", PS('body')))
# ... rest of the code from the question
It looks like the reportlab.lib.sequencer.Sequencer object being used is global.
You can reset all counters by supplying a new Sequencer
from reportlab.lib.sequencer import setSequencer, Sequencer
setSequencer(Sequencer())
You can reset a single counter by doing something like:
from reportlab.lib.sequencer import getSequencer
s = getSequencer()
s.reset('h1')
You could also try using Sequencer directly instead of injecting XML.
Class Sequencer: Something to make it easy to number paragraphs,
sections, images and anything else. The features include registering
new string formats for sequences, and 'chains' whereby some counters
are reset when their parents. It keeps track of a number of
'counters', which are created on request.
Usage::
>>> seq = Sequencer()
>>> seq.next('Bullets')
1
>>> seq.next('Bullets')
2
>>> seq.next('Bullets')
3
>>> seq.reset('Bullets')
>>> seq.next('Bullets')
1
>>> seq.next('Figures')
1
>>>

Dynamic Spacer in ReportLab

I'm automatically generating a PDF-file with Platypus that has dynamic content.
This means that it might happen that the length of the text content (which is directly at the bottom of the pdf-file) may vary.
However, it might happen that a page break is done in cases where the content is too long.
This is because i use a "static" spacer:
s = Spacer(width=0, height=23.5*cm)
as i always want to have only one page, I somehow need to dynamically set the height of the Spacer, so that the "rest" of the space that is left on the page is taken by the Spacer as its height.
Now, how do i get the "rest" of height that is left on my page?
I sniffed around in the reportlab library a bit and found the following:
Basically, I decided to use a frame into which the flowables will be printed. f._aH returns the height of the Frame (we could also calculate this by hand). Subtracting the heights of the other two flowables, which we get through wrap, we get the remaining height which is the height of the Spacer.
elements.append(Flowable1)
elements.append(Flowable2)
c = Canvas(path)
f = Frame(fx, fy,fw,fh,showBoundary=0)
# compute the available height for the spacer
sheight = f._aH - (Flowable1.wrap(f._aW,f._aH)[1] + Flowable2.wrap(f._aW,f._aH)[1])
# create spacer
s = Spacer(width=0, height=sheight)
# insert the spacer between the two flowables
elements.insert(1,s)
# create a frame from the list of elements
f.addFromList(elements,c)
c.save()
tested and works fine.
As far as i can see you want to have footer, right?
Then you should do it like:
def _laterPages(canvas, doc):
canvas.drawImage(os.path.join(settings.PROJECT_ROOT, 'templates/documents/pics/footer.png'), left_margin, bottom_margin - 0.5*cm, frame_width, 0.5*cm)
doc = BaseDocTemplate(filename,showBoundary=False)
doc.multiBuild(flowble elements, _firstPage, _laterPages)

Categories