Skip to content

Commit 9dbbbda

Browse files
authored
smartart parsing (#1)
1 parent 278b47b commit 9dbbbda

15 files changed

Lines changed: 990 additions & 4 deletions

File tree

‎src/pptx/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
from pptx.opc.package import PartFactory
1212
from pptx.parts.chart import ChartPart
1313
from pptx.parts.coreprops import CorePropertiesPart
14+
from pptx.parts.diagram import (
15+
DiagramColorsPart,
16+
DiagramDataPart,
17+
DiagramDrawingPart,
18+
DiagramLayoutPart,
19+
DiagramStylePart,
20+
)
1421
from pptx.parts.image import ImagePart
1522
from pptx.parts.media import MediaPart
1623
from pptx.parts.presentation import PresentationPart
@@ -44,6 +51,11 @@
4451
CT.PML_SLIDE_LAYOUT: SlideLayoutPart,
4552
CT.PML_SLIDE_MASTER: SlideMasterPart,
4653
CT.DML_CHART: ChartPart,
54+
CT.DML_DIAGRAM_DATA: DiagramDataPart,
55+
CT.DML_DIAGRAM_LAYOUT: DiagramLayoutPart,
56+
CT.DML_DIAGRAM_COLORS: DiagramColorsPart,
57+
CT.DML_DIAGRAM_STYLE: DiagramStylePart,
58+
CT.DML_DIAGRAM_DRAWING: DiagramDrawingPart,
4759
CT.BMP: ImagePart,
4860
CT.GIF: ImagePart,
4961
CT.JPEG: ImagePart,

‎src/pptx/enum/shapes.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,9 @@ class MSO_SHAPE_TYPE(BaseEnum):
804804
IGX_GRAPHIC = (24, "SmartArt graphic")
805805
"""SmartArt graphic"""
806806

807+
SMART_ART = (24, "SmartArt graphic")
808+
"""SmartArt graphic (alias for IGX_GRAPHIC)"""
809+
807810
INK = (22, "Ink")
808811
"""Ink"""
809812

‎src/pptx/opc/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ class RELATIONSHIP_TYPE:
231231
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors"
232232
)
233233
DIAGRAM_DATA = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData"
234+
DIAGRAM_DRAWING = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"
234235
DIAGRAM_LAYOUT = (
235236
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout"
236237
)

‎src/pptx/oxml/__init__.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,23 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
272272
register_element_cls("a:prstDash", CT_PresetLineDashProperties)
273273

274274

275+
from pptx.oxml.diagram import ( # noqa: E402
276+
CT_DiagramConnection,
277+
CT_DiagramConnectionList,
278+
CT_DiagramData,
279+
CT_DiagramPoint,
280+
CT_DiagramPointList,
281+
CT_DiagramText,
282+
)
283+
284+
register_element_cls("dgm:dataModel", CT_DiagramData)
285+
register_element_cls("dgm:ptLst", CT_DiagramPointList)
286+
register_element_cls("dgm:pt", CT_DiagramPoint)
287+
register_element_cls("dgm:t", CT_DiagramText)
288+
register_element_cls("dgm:cxnLst", CT_DiagramConnectionList)
289+
register_element_cls("dgm:cxn", CT_DiagramConnection)
290+
291+
275292
from pptx.oxml.presentation import ( # noqa: E402
276293
CT_Presentation,
277294
CT_SlideId,

‎src/pptx/oxml/diagram.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""lxml custom element classes for diagram (SmartArt) elements."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Iterator
6+
7+
from pptx.oxml.ns import qn
8+
from pptx.oxml.xmlchemy import BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, ZeroOrOne
9+
from pptx.oxml.simpletypes import XsdString
10+
11+
if TYPE_CHECKING:
12+
pass
13+
14+
15+
class CT_DiagramData(BaseOxmlElement):
16+
"""<dgm:dataModel> element - root element of diagram data."""
17+
18+
ptLst: CT_DiagramPointList = OneAndOnlyOne( # pyright: ignore[reportAssignmentType]
19+
"dgm:ptLst"
20+
)
21+
cxnLst: CT_DiagramConnectionList = OneAndOnlyOne( # pyright: ignore[reportAssignmentType]
22+
"dgm:cxnLst"
23+
)
24+
25+
26+
class CT_DiagramPointList(BaseOxmlElement):
27+
"""<dgm:ptLst> element - list of diagram points (nodes)."""
28+
29+
def iter_pts(self) -> Iterator[CT_DiagramPoint]:
30+
"""Generate each <dgm:pt> child element."""
31+
for pt in self.findall(qn("dgm:pt")):
32+
yield pt
33+
34+
35+
class CT_DiagramPoint(BaseOxmlElement):
36+
"""<dgm:pt> element - a node in the diagram."""
37+
38+
modelId: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
39+
"modelId", XsdString
40+
)
41+
type: str | None = OptionalAttribute("type", XsdString) # pyright: ignore[reportAssignmentType]
42+
t: CT_DiagramText | None = ZeroOrOne("dgm:t") # pyright: ignore[reportAssignmentType]
43+
44+
@property
45+
def text(self) -> str:
46+
"""Extract all text content from this point."""
47+
if self.t is None:
48+
return ""
49+
# Extract text from all <a:t> elements
50+
text_elements = self.t.findall(".//" + qn("a:t"))
51+
return "".join(el.text for el in text_elements if el.text)
52+
53+
@text.setter
54+
def text(self, value: str) -> None:
55+
"""Set text content for this point."""
56+
if self.t is None:
57+
return
58+
# Find all <a:t> elements and set the text to the first one, clear others
59+
text_elements = self.t.findall(".//" + qn("a:t"))
60+
if text_elements:
61+
# Set text in first element, clear the rest
62+
text_elements[0].text = value
63+
for el in text_elements[1:]:
64+
el.text = ""
65+
66+
67+
class CT_DiagramText(BaseOxmlElement):
68+
"""<dgm:t> element - text container in a diagram point."""
69+
70+
pass
71+
72+
73+
class CT_DiagramConnectionList(BaseOxmlElement):
74+
"""<dgm:cxnLst> element - list of connections between points."""
75+
76+
def iter_cxns(self) -> Iterator[CT_DiagramConnection]:
77+
"""Generate each <dgm:cxn> child element."""
78+
for cxn in self.findall(qn("dgm:cxn")):
79+
yield cxn
80+
81+
82+
class CT_DiagramConnection(BaseOxmlElement):
83+
"""<dgm:cxn> element - connection/relationship between diagram points."""
84+
85+
modelId: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
86+
"modelId", XsdString
87+
)
88+
srcId: str | None = OptionalAttribute("srcId", XsdString) # pyright: ignore[reportAssignmentType]
89+
destId: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
90+
"destId", XsdString
91+
)
92+
type: str | None = OptionalAttribute("type", XsdString) # pyright: ignore[reportAssignmentType]

‎src/pptx/oxml/ns.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"dc": "http://purl.org/dc/elements/1.1/",
1313
"dcmitype": "http://purl.org/dc/dcmitype/",
1414
"dcterms": "http://purl.org/dc/terms/",
15+
"dgm": "http://schemas.openxmlformats.org/drawingml/2006/diagram",
1516
"ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",
1617
"i": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
1718
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",

‎src/pptx/oxml/shapes/graphfrm.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from pptx.oxml import parse_xml
88
from pptx.oxml.chart.chart import CT_Chart
9-
from pptx.oxml.ns import nsdecls
9+
from pptx.oxml.ns import nsdecls, qn
1010
from pptx.oxml.shapes.shared import BaseShapeElement
1111
from pptx.oxml.simpletypes import XsdBoolean, XsdString
1212
from pptx.oxml.table import CT_Table
@@ -19,6 +19,7 @@
1919
)
2020
from pptx.spec import (
2121
GRAPHIC_DATA_URI_CHART,
22+
GRAPHIC_DATA_URI_DIAGRAM,
2223
GRAPHIC_DATA_URI_OLEOBJ,
2324
GRAPHIC_DATA_URI_TABLE,
2425
)
@@ -101,6 +102,29 @@ def showAsIcon(self) -> bool | None:
101102
"""
102103
return None if self._oleObj is None else self._oleObj.showAsIcon
103104

105+
@property
106+
def diagram_rIds(self) -> tuple[str, str, str, str, str | None] | None:
107+
"""Return diagram relationship IDs (dm, lo, qs, cs, draw).
108+
109+
Returns None if this graphic data does not contain a diagram.
110+
Returns tuple of (data_rId, layout_rId, style_rId, colors_rId, drawing_rId).
111+
The drawing_rId may be None as it's a Microsoft extension.
112+
"""
113+
if self.uri != GRAPHIC_DATA_URI_DIAGRAM:
114+
return None
115+
116+
relIds = self.find(qn("dgm:relIds"))
117+
if relIds is None:
118+
return None
119+
120+
return (
121+
relIds.get(qn("r:dm")),
122+
relIds.get(qn("r:lo")),
123+
relIds.get(qn("r:qs")),
124+
relIds.get(qn("r:cs")),
125+
relIds.get(qn("r:draw")),
126+
)
127+
104128
@property
105129
def _oleObj(self) -> CT_OleObject | None:
106130
"""Optional `p:oleObj` element contained in this `p:graphicData' element.
@@ -163,6 +187,17 @@ def graphicData_uri(self) -> str:
163187
"""str value of `uri` attribute of `a:graphicData` grandchild."""
164188
return self.graphic.graphicData.uri
165189

190+
@property
191+
def diagram_data_rId(self) -> str | None:
192+
"""Relationship ID of diagram data part, or None if not a diagram."""
193+
rIds = self.graphicData.diagram_rIds
194+
return rIds[0] if rIds else None
195+
196+
@property
197+
def has_diagram(self) -> bool:
198+
"""`True` for graphicFrame containing a diagram (SmartArt), `False` otherwise."""
199+
return self.graphicData.uri == GRAPHIC_DATA_URI_DIAGRAM
200+
166201
@property
167202
def has_oleobj(self) -> bool:
168203
"""`True` for graphicFrame containing an OLE object, `False` otherwise."""

‎src/pptx/parts/diagram.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Diagram (SmartArt) related parts."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, cast
6+
7+
from pptx.opc.package import Part, XmlPart
8+
from pptx.oxml import parse_xml
9+
10+
if TYPE_CHECKING:
11+
from pptx.opc.package import Package
12+
from pptx.opc.packuri import PackURI
13+
from pptx.oxml.diagram import CT_DiagramData
14+
15+
16+
class DiagramDataPart(XmlPart):
17+
"""Diagram data part - contains the SmartArt data model."""
18+
19+
@property
20+
def data_model(self) -> CT_DiagramData:
21+
"""The CT_DiagramData root element."""
22+
return cast("CT_DiagramData", self._element)
23+
24+
@classmethod
25+
def load(
26+
cls, partname: PackURI, content_type: str, package: Package, blob: bytes
27+
) -> DiagramDataPart:
28+
"""Called by PartFactory to load diagram data part."""
29+
return cls(partname, content_type, package, element=parse_xml(blob))
30+
31+
32+
class DiagramLayoutPart(Part):
33+
"""Diagram layout part - contains layout definition."""
34+
35+
pass
36+
37+
38+
class DiagramColorsPart(Part):
39+
"""Diagram colors part - contains color scheme."""
40+
41+
pass
42+
43+
44+
class DiagramStylePart(Part):
45+
"""Diagram style part - contains quick style."""
46+
47+
pass
48+
49+
50+
class DiagramDrawingPart(XmlPart):
51+
"""Diagram drawing part - contains pre-rendered drawing (MS extension)."""
52+
53+
def update_all_text_elements(self, text_list: list[str]) -> None:
54+
"""Update all text elements in drawing order with new texts."""
55+
from pptx.oxml.ns import qn
56+
57+
# Find all <a:t> elements in the drawing
58+
text_elements = self._element.findall(".//" + qn("a:t"))
59+
60+
# Update each text element with corresponding text from list
61+
for idx, text_el in enumerate(text_elements):
62+
if idx < len(text_list):
63+
text_el.text = text_list[idx]
64+
65+
@classmethod
66+
def load(
67+
cls, partname: PackURI, content_type: str, package: Package, blob: bytes
68+
) -> DiagramDrawingPart:
69+
"""Called by PartFactory to load diagram drawing part."""
70+
return cls(partname, content_type, package, element=parse_xml(blob))

‎src/pptx/shapes/graphfrm.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pptx.shared import ParentedElementProxy
1414
from pptx.spec import (
1515
GRAPHIC_DATA_URI_CHART,
16+
GRAPHIC_DATA_URI_DIAGRAM,
1617
GRAPHIC_DATA_URI_OLEOBJ,
1718
GRAPHIC_DATA_URI_TABLE,
1819
)
@@ -24,7 +25,9 @@
2425
from pptx.dml.effect import ShadowFormat
2526
from pptx.oxml.shapes.graphfrm import CT_GraphicalObjectData, CT_GraphicalObjectFrame
2627
from pptx.parts.chart import ChartPart
28+
from pptx.parts.diagram import DiagramDataPart
2729
from pptx.parts.slide import BaseSlidePart
30+
from pptx.shapes.smartart import SmartArt
2831
from pptx.types import ProvidesPart
2932

3033

@@ -64,6 +67,14 @@ def has_chart(self) -> bool:
6467
"""
6568
return self._graphicFrame.graphicData_uri == GRAPHIC_DATA_URI_CHART
6669

70+
@property
71+
def has_smartart(self) -> bool:
72+
"""|True| if this graphic frame contains SmartArt, |False| otherwise.
73+
74+
When |True|, the SmartArt object can be accessed using the `.smartart` property.
75+
"""
76+
return self._graphicFrame.graphicData_uri == GRAPHIC_DATA_URI_DIAGRAM
77+
6778
@property
6879
def has_table(self) -> bool:
6980
"""|True| if this graphic frame contains a table object, |False| otherwise.
@@ -94,21 +105,41 @@ def shadow(self) -> ShadowFormat:
94105
"""
95106
raise NotImplementedError("shadow property on GraphicFrame not yet supported")
96107

108+
@property
109+
def smartart(self) -> SmartArt:
110+
"""The |SmartArt| object contained in this graphic frame.
111+
112+
Raises |ValueError| if this graphic frame does not contain SmartArt.
113+
"""
114+
if not self.has_smartart:
115+
raise ValueError("shape does not contain SmartArt")
116+
117+
from pptx.shapes.smartart import SmartArt
118+
119+
data_rId = self._graphicFrame.diagram_data_rId
120+
if data_rId is None:
121+
raise ValueError("SmartArt data relationship not found")
122+
123+
data_part = cast("DiagramDataPart", self.part.related_part(data_rId))
124+
return SmartArt(data_part, self)
125+
97126
@property
98127
def shape_type(self) -> MSO_SHAPE_TYPE:
99128
"""Optional member of `MSO_SHAPE_TYPE` identifying the type of this shape.
100129
101130
Possible values are `MSO_SHAPE_TYPE.CHART`, `MSO_SHAPE_TYPE.TABLE`,
102-
`MSO_SHAPE_TYPE.EMBEDDED_OLE_OBJECT`, `MSO_SHAPE_TYPE.LINKED_OLE_OBJECT`.
131+
`MSO_SHAPE_TYPE.SMART_ART`, `MSO_SHAPE_TYPE.EMBEDDED_OLE_OBJECT`,
132+
`MSO_SHAPE_TYPE.LINKED_OLE_OBJECT`.
103133
104-
This value is `None` when none of these four types apply, for example when the shape
105-
contains SmartArt.
134+
This value is `None` when none of these types apply.
106135
"""
107136
graphicData_uri = self._graphicFrame.graphicData_uri
108137
if graphicData_uri == GRAPHIC_DATA_URI_CHART:
109138
return MSO_SHAPE_TYPE.CHART
110139
elif graphicData_uri == GRAPHIC_DATA_URI_TABLE:
111140
return MSO_SHAPE_TYPE.TABLE
141+
elif graphicData_uri == GRAPHIC_DATA_URI_DIAGRAM:
142+
return MSO_SHAPE_TYPE.SMART_ART
112143
elif graphicData_uri == GRAPHIC_DATA_URI_OLEOBJ:
113144
return (
114145
MSO_SHAPE_TYPE.EMBEDDED_OLE_OBJECT

0 commit comments

Comments
 (0)