Skip to content

Commit 2e74a16

Browse files
MHoroszowskiclaude
andcommitted
feature: add bullet and numbered list formatting to paragraphs
Add bullet_type, bullet_char, bullet_auto_number_type, and bullet_font properties to _Paragraph for programmatic bullet/numbered list creation. Introduces PP_BULLET_TYPE and PP_AUTO_NUMBER_STYLE enums and models the bullet choice group (a:buNone, a:buChar, a:buAutoNum) and a:buFont element on CT_TextParagraphProperties. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 296ca19 commit 2e74a16

File tree

4 files changed

+255
-2
lines changed

4 files changed

+255
-2
lines changed

src/pptx/enum/text.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,82 @@ class PP_PARAGRAPH_ALIGNMENT(BaseXmlEnum):
228228

229229

230230
PP_ALIGN = PP_PARAGRAPH_ALIGNMENT
231+
232+
233+
class PP_BULLET_TYPE(BaseEnum):
234+
"""Specifies the type of bullet formatting applied to a paragraph.
235+
236+
Example::
237+
238+
from pptx.enum.text import PP_BULLET_TYPE
239+
240+
paragraph.bullet_type = PP_BULLET_TYPE.CHARACTER
241+
242+
Not a direct MS API mapping; describes the DrawingML bullet choice group.
243+
"""
244+
245+
NONE = (1, "No bullet formatting. Uses `a:buNone` element.")
246+
"""No bullet formatting. Uses `a:buNone` element."""
247+
248+
CHARACTER = (2, "A character bullet such as '•'. Uses `a:buChar` element.")
249+
"""A character bullet such as '•'. Uses `a:buChar` element."""
250+
251+
AUTO_NUMBER = (3, "Auto-numbered bullet. Uses `a:buAutoNum` element.")
252+
"""Auto-numbered bullet. Uses `a:buAutoNum` element."""
253+
254+
255+
class PP_AUTO_NUMBER_STYLE(BaseXmlEnum):
256+
"""Specifies the auto-number style for a numbered bullet list.
257+
258+
Example::
259+
260+
from pptx.enum.text import PP_AUTO_NUMBER_STYLE
261+
262+
paragraph.bullet_auto_number_type = PP_AUTO_NUMBER_STYLE.ARABIC_PERIOD
263+
264+
Maps to the `type` attribute of the `a:buAutoNum` element.
265+
"""
266+
267+
ARABIC_PERIOD = (1, "arabicPeriod", "Arabic numerals followed by a period: 1. 2. 3.")
268+
"""Arabic numerals followed by a period: 1. 2. 3."""
269+
270+
ARABIC_PAREN_RIGHT = (2, "arabicParenR", "Arabic numerals followed by a parenthesis: 1) 2) 3)")
271+
"""Arabic numerals followed by a parenthesis: 1) 2) 3)"""
272+
273+
ARABIC_PAREN_BOTH = (3, "arabicParenBoth", "Arabic numerals in parentheses: (1) (2) (3)")
274+
"""Arabic numerals in parentheses: (1) (2) (3)"""
275+
276+
ARABIC_PLAIN = (4, "arabicPlain", "Arabic numerals: 1 2 3")
277+
"""Arabic numerals: 1 2 3"""
278+
279+
ROMAN_UC_PERIOD = (5, "romanUcPeriod", "Uppercase Roman numerals with period: I. II. III.")
280+
"""Uppercase Roman numerals with period: I. II. III."""
281+
282+
ROMAN_LC_PERIOD = (6, "romanLcPeriod", "Lowercase Roman numerals with period: i. ii. iii.")
283+
"""Lowercase Roman numerals with period: i. ii. iii."""
284+
285+
ALPHA_UC_PERIOD = (7, "alphaUcPeriod", "Uppercase letters with period: A. B. C.")
286+
"""Uppercase letters with period: A. B. C."""
287+
288+
ALPHA_LC_PERIOD = (8, "alphaLcPeriod", "Lowercase letters with period: a. b. c.")
289+
"""Lowercase letters with period: a. b. c."""
290+
291+
ALPHA_UC_PAREN_RIGHT = (
292+
9, "alphaUcParenR", "Uppercase letters with parenthesis: A) B) C)"
293+
)
294+
"""Uppercase letters with parenthesis: A) B) C)"""
295+
296+
ALPHA_LC_PAREN_RIGHT = (
297+
10, "alphaLcParenR", "Lowercase letters with parenthesis: a) b) c)"
298+
)
299+
"""Lowercase letters with parenthesis: a) b) c)"""
300+
301+
ALPHA_UC_PAREN_BOTH = (
302+
11, "alphaUcParenBoth", "Uppercase letters in parentheses: (A) (B) (C)"
303+
)
304+
"""Uppercase letters in parentheses: (A) (B) (C)"""
305+
306+
ALPHA_LC_PAREN_BOTH = (
307+
12, "alphaLcParenBoth", "Lowercase letters in parentheses: (a) (b) (c)"
308+
)
309+
"""Lowercase letters in parentheses: (a) (b) (c)"""

src/pptx/oxml/text.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,18 @@ class CT_TextParagraphProperties(BaseOxmlElement):
498498
spcAft: CT_TextSpacing | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
499499
"a:spcAft", successors=_tag_seq[3:]
500500
)
501+
buFont: BaseOxmlElement | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
502+
"a:buFont", successors=_tag_seq[10:]
503+
)
504+
eg_buTypeface = ZeroOrOneChoice(
505+
(
506+
Choice("a:buNone"),
507+
Choice("a:buAutoNum"),
508+
Choice("a:buChar"),
509+
Choice("a:buBlip"),
510+
),
511+
successors=_tag_seq[14:],
512+
)
501513
defRPr: CT_TextCharacterProperties | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
502514
"a:defRPr", successors=_tag_seq[16:]
503515
)

src/pptx/text/text.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
from pptx.dml.fill import FillFormat
88
from pptx.enum.dml import MSO_FILL
99
from pptx.enum.lang import MSO_LANGUAGE_ID
10-
from pptx.enum.text import MSO_AUTO_SIZE, MSO_UNDERLINE, MSO_VERTICAL_ANCHOR
10+
from pptx.enum.text import (
11+
MSO_AUTO_SIZE,
12+
MSO_UNDERLINE,
13+
MSO_VERTICAL_ANCHOR,
14+
PP_AUTO_NUMBER_STYLE,
15+
PP_BULLET_TYPE,
16+
)
1117
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
1218
from pptx.oxml.simpletypes import ST_TextWrappingType
1319
from pptx.shapes import Subshape
@@ -494,6 +500,113 @@ def alignment(self) -> PP_PARAGRAPH_ALIGNMENT | None:
494500
def alignment(self, value: PP_PARAGRAPH_ALIGNMENT | None):
495501
self._pPr.algn = value
496502

503+
@property
504+
def bullet_char(self) -> str | None:
505+
"""Character used for bullet, e.g. '•'.
506+
507+
Read/write. Returns |None| if the paragraph does not have a character bullet. Setting this
508+
property also sets `bullet_type` to `PP_BULLET_TYPE.CHARACTER`.
509+
"""
510+
pPr = self._p.pPr
511+
if pPr is None:
512+
return None
513+
buChar = pPr.buChar
514+
if buChar is None:
515+
return None
516+
return buChar.get("char")
517+
518+
@bullet_char.setter
519+
def bullet_char(self, value: str | None) -> None:
520+
pPr = self._p.get_or_add_pPr()
521+
if value is None:
522+
pPr._remove_eg_buTypeface()
523+
return
524+
buChar = pPr.get_or_change_to_buChar()
525+
buChar.set("char", value)
526+
527+
@property
528+
def bullet_type(self) -> PP_BULLET_TYPE | None:
529+
"""Type of bullet formatting on this paragraph.
530+
531+
Read/write. Returns a member of :ref:`PpBulletType` or |None| if no explicit bullet
532+
formatting is set. Assigning |None| removes bullet formatting.
533+
"""
534+
pPr = self._p.pPr
535+
if pPr is None:
536+
return None
537+
bu = pPr.eg_buTypeface
538+
if bu is None:
539+
return None
540+
tag = bu.tag.split("}")[-1]
541+
return {
542+
"buNone": PP_BULLET_TYPE.NONE,
543+
"buChar": PP_BULLET_TYPE.CHARACTER,
544+
"buAutoNum": PP_BULLET_TYPE.AUTO_NUMBER,
545+
}.get(tag)
546+
547+
@bullet_type.setter
548+
def bullet_type(self, value: PP_BULLET_TYPE | None) -> None:
549+
pPr = self._p.get_or_add_pPr()
550+
if value is None:
551+
pPr._remove_eg_buTypeface()
552+
return
553+
method_map = {
554+
PP_BULLET_TYPE.NONE: "get_or_change_to_buNone",
555+
PP_BULLET_TYPE.CHARACTER: "get_or_change_to_buChar",
556+
PP_BULLET_TYPE.AUTO_NUMBER: "get_or_change_to_buAutoNum",
557+
}
558+
getattr(pPr, method_map[value])()
559+
560+
@property
561+
def bullet_auto_number_type(self) -> PP_AUTO_NUMBER_STYLE | None:
562+
"""Auto-number style for this paragraph's bullet.
563+
564+
Read/write. Returns a member of :ref:`PpAutoNumberStyle` or |None|. Setting this property
565+
also sets `bullet_type` to `PP_BULLET_TYPE.AUTO_NUMBER`.
566+
"""
567+
pPr = self._p.pPr
568+
if pPr is None:
569+
return None
570+
buAutoNum = pPr.buAutoNum
571+
if buAutoNum is None:
572+
return None
573+
type_val = buAutoNum.get("type")
574+
if type_val is None:
575+
return None
576+
return PP_AUTO_NUMBER_STYLE.from_xml(type_val)
577+
578+
@bullet_auto_number_type.setter
579+
def bullet_auto_number_type(self, value: PP_AUTO_NUMBER_STYLE | None) -> None:
580+
pPr = self._p.get_or_add_pPr()
581+
if value is None:
582+
pPr._remove_eg_buTypeface()
583+
return
584+
buAutoNum = pPr.get_or_change_to_buAutoNum()
585+
buAutoNum.set("type", value.xml_value)
586+
587+
@property
588+
def bullet_font(self) -> str | None:
589+
"""Typeface name for bullet character.
590+
591+
Read/write. Returns |None| if no explicit bullet font is set.
592+
"""
593+
pPr = self._p.pPr
594+
if pPr is None:
595+
return None
596+
buFont = pPr.buFont
597+
if buFont is None:
598+
return None
599+
return buFont.get("typeface")
600+
601+
@bullet_font.setter
602+
def bullet_font(self, value: str | None) -> None:
603+
pPr = self._p.get_or_add_pPr()
604+
if value is None:
605+
pPr._remove_buFont()
606+
return
607+
buFont = pPr.get_or_add_buFont()
608+
buFont.set("typeface", value)
609+
497610
def clear(self):
498611
"""Remove all content from this paragraph.
499612

tests/text/test_text.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pptx.dml.color import ColorFormat
1212
from pptx.dml.fill import FillFormat
1313
from pptx.enum.lang import MSO_LANGUAGE_ID
14-
from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, MSO_UNDERLINE, PP_ALIGN
14+
from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, MSO_UNDERLINE, PP_ALIGN, PP_BULLET_TYPE
1515
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
1616
from pptx.opc.package import XmlPart
1717
from pptx.shapes.autoshape import Shape
@@ -807,6 +807,55 @@ def it_can_change_its_horizontal_alignment(self, alignment_set_fixture):
807807
paragraph.alignment = new_value
808808
assert paragraph._element.xml == expected_xml
809809

810+
@pytest.mark.parametrize(
811+
("p_cxml", "expected_value"),
812+
[
813+
("a:p", None),
814+
("a:p/a:pPr", None),
815+
("a:p/a:pPr/a:buNone", PP_BULLET_TYPE.NONE),
816+
("a:p/a:pPr/a:buChar{char=-}", PP_BULLET_TYPE.CHARACTER),
817+
("a:p/a:pPr/a:buAutoNum{type=arabicPeriod}", PP_BULLET_TYPE.AUTO_NUMBER),
818+
],
819+
)
820+
def it_knows_its_bullet_type(self, p_cxml: str, expected_value):
821+
paragraph = _Paragraph(element(p_cxml), None)
822+
assert paragraph.bullet_type == expected_value
823+
824+
@pytest.mark.parametrize(
825+
("p_cxml", "value", "expected_tag"),
826+
[
827+
("a:p", PP_BULLET_TYPE.CHARACTER, "buChar"),
828+
("a:p", PP_BULLET_TYPE.AUTO_NUMBER, "buAutoNum"),
829+
("a:p", PP_BULLET_TYPE.NONE, "buNone"),
830+
("a:p/a:pPr/a:buChar{char=-}", PP_BULLET_TYPE.NONE, "buNone"),
831+
],
832+
)
833+
def it_can_change_its_bullet_type(self, p_cxml: str, value, expected_tag: str):
834+
p = element(p_cxml)
835+
_Paragraph(p, None).bullet_type = value
836+
pPr = p.pPr
837+
bu = pPr.eg_buTypeface
838+
assert bu is not None
839+
assert bu.tag.endswith(expected_tag)
840+
841+
@pytest.mark.parametrize(
842+
("p_cxml", "expected_value"),
843+
[
844+
("a:p", None),
845+
("a:p/a:pPr/a:buChar{char=-}", "-"),
846+
],
847+
)
848+
def it_knows_its_bullet_char(self, p_cxml: str, expected_value: str | None):
849+
paragraph = _Paragraph(element(p_cxml), None)
850+
assert paragraph.bullet_char == expected_value
851+
852+
def it_can_set_its_bullet_char(self):
853+
p = element("a:p")
854+
paragraph = _Paragraph(p, None)
855+
paragraph.bullet_char = "-"
856+
assert paragraph.bullet_type == PP_BULLET_TYPE.CHARACTER
857+
assert paragraph.bullet_char == "-"
858+
810859
def it_can_clear_itself_of_content(self, clear_fixture):
811860
paragraph, expected_xml = clear_fixture
812861
paragraph.clear()

0 commit comments

Comments
 (0)