forked from scanny/python-pptx
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathshapetree.py
More file actions
1388 lines (1153 loc) · 54.8 KB
/
Copy pathshapetree.py
File metadata and controls
1388 lines (1153 loc) · 54.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""The shape tree, the structure that holds a slide's shapes."""
from __future__ import annotations
import io
import os
from typing import IO, TYPE_CHECKING, Callable, Iterable, Iterator, cast
from pptx.enum.shapes import PP_PLACEHOLDER, PROG_ID
from pptx.media import SPEAKER_IMAGE_BYTES, Video
from pptx.opc.constants import CONTENT_TYPE as CT
from pptx.oxml.ns import qn
from pptx.oxml.shapes.autoshape import CT_Shape
from pptx.oxml.shapes.graphfrm import CT_GraphicalObjectFrame
from pptx.oxml.shapes.picture import CT_Picture
from pptx.oxml.simpletypes import ST_Direction
from pptx.shapes.autoshape import AutoShapeType, Shape
from pptx.shapes.base import BaseShape
from pptx.shapes.connector import Connector
from pptx.shapes.freeform import FreeformBuilder
from pptx.shapes.graphfrm import GraphicFrame
from pptx.shapes.group import GroupShape
from pptx.shapes.picture import Movie, Picture
from pptx.shapes.placeholder import (
ChartPlaceholder,
LayoutPlaceholder,
MasterPlaceholder,
NotesSlidePlaceholder,
PicturePlaceholder,
PlaceholderGraphicFrame,
PlaceholderPicture,
SlidePlaceholder,
TablePlaceholder,
)
from pptx.shared import ParentedElementProxy
from pptx.util import Emu, lazyproperty
if TYPE_CHECKING:
from pptx.chart.chart import Chart
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.enum.shapes import MSO_CONNECTOR_TYPE, MSO_SHAPE
from pptx.oxml.shapes import ShapeElement
from pptx.oxml.shapes.connector import CT_Connector
from pptx.oxml.shapes.groupshape import CT_GroupShape
from pptx.parts.image import ImagePart
from pptx.parts.slide import SlidePart
from pptx.slide import Slide, SlideLayout
from pptx.types import ProvidesPart
from pptx.util import Length
# +-- _BaseShapes
# | |
# | +-- _BaseGroupShapes
# | | |
# | | +-- GroupShapes
# | | |
# | | +-- SlideShapes
# | |
# | +-- LayoutShapes
# | |
# | +-- MasterShapes
# | |
# | +-- NotesSlideShapes
# | |
# | +-- BasePlaceholders
# | |
# | +-- LayoutPlaceholders
# | |
# | +-- MasterPlaceholders
# | |
# | +-- NotesSlidePlaceholders
# |
# +-- SlidePlaceholders
class _BaseShapes(ParentedElementProxy):
"""Base class for a shape collection appearing in a slide-type object.
Subclasses include Slide, SlideLayout, and SlideMaster. Provides common methods.
"""
def __init__(self, spTree: CT_GroupShape, parent: ProvidesPart):
super(_BaseShapes, self).__init__(spTree, parent)
self._spTree = spTree
self._cached_max_shape_id = None
def __getitem__(self, key: int | str) -> BaseShape:
"""Return shape at `key`. Mapping-like dispatch by key type.
- Integer ``key`` returns the shape at that index in document
order, e.g. ``shapes[2]``. Raises |IndexError| if out of range.
- String ``key`` returns the shape whose ``.name`` equals ``key``
(the same lookup as :meth:`by_name`), e.g. ``shapes["Title 1"]``.
Raises |KeyError| with a clear message on miss.
``bool`` keys are rejected (|TypeError|) — they're a subclass of
``int`` so would otherwise silently resolve to index 0/1, which
is almost certainly an unintended call.
Closes scanny/python-pptx#800.
"""
if isinstance(key, bool):
raise TypeError("shape key must be int or str, got bool")
if isinstance(key, str):
return self.by_name(key)
shape_elms = list(self._iter_member_elms())
try:
shape_elm = shape_elms[key]
except IndexError:
raise IndexError("shape index out of range")
return self._shape_factory(shape_elm)
def __iter__(self) -> Iterator[BaseShape]:
"""Generate a reference to each shape in the collection, in sequence."""
for shape_elm in self._iter_member_elms():
yield self._shape_factory(shape_elm)
def __len__(self) -> int:
"""Return count of shapes in this shape tree.
A group shape contributes 1 to the total, without regard to the number of shapes contained
in the group.
"""
shape_elms = list(self._iter_member_elms())
return len(shape_elms)
def by_name(self, name: str) -> BaseShape:
"""Return the first shape in this collection whose `.name` equals `name`.
Lookup is case-sensitive, matching PowerPoint's own behavior. When
multiple shapes share the same name (uncommon but possible —
PowerPoint does not enforce uniqueness), the first match in
document order is returned. Raises |KeyError| with a clear message
if no match is found.
Closes scanny/python-pptx#798, scanny/python-pptx#309, and
scanny/python-pptx#532.
"""
for shape in self:
if shape.name == name:
return shape
raise KeyError("no shape named %r in this collection" % name)
def __contains__(self, key: object) -> bool:
"""Mapping-like membership: `"Title 1" in shapes` checks names.
- String key: True when any shape in this collection has a matching
``.name`` (case-sensitive).
- Integer key: True when ``0 <= key < len(self)`` — sequence-style
index range check, matching `__getitem__(int)` semantics.
``bool`` and other key types return False (no implicit coercion;
bools rejected for the same reason `__getitem__` rejects them —
``True``/``False`` as an index is almost always a bug).
"""
if isinstance(key, bool):
return False
if isinstance(key, str):
return any(shape.name == key for shape in self)
if isinstance(key, int):
return 0 <= key < len(self)
return False
def keys(self) -> list[str]:
"""List of every shape's ``.name`` in document order.
Mapping-like helper. Names may not be unique (PowerPoint doesn't
enforce); duplicates appear in iteration order.
"""
return [shape.name for shape in self]
def iter_leaf_shapes(self) -> Iterator[BaseShape]:
"""Recursively yield every non-group shape in this collection.
Descends into `GroupShape` children; the group containers themselves
are NOT yielded — only the leaf shapes (autoshapes, pictures,
connectors, text frames, tables, charts, placeholders, etc.) inside
them. A consumer wanting the group containers should use the
regular `for shape in shapes` iteration.
Closes scanny/python-pptx#435.
"""
# ---deferred import to avoid circular dependency---
from pptx.shapes.group import GroupShape
for shape in self:
if isinstance(shape, GroupShape):
yield from shape.shapes.iter_leaf_shapes()
else:
yield shape
def in_selection_pane_order(self) -> tuple[BaseShape, ...]:
"""Return shapes in PowerPoint's Selection Pane order.
The Selection Pane lists shapes from top-most (most recently drawn,
rendered on top) to bottom-most. Top-most in PowerPoint is the
last child in XML document order, so this is the reverse of
``tuple(self)``. Read-only snapshot — does not auto-update if
the collection changes after the call.
Closes scanny/python-pptx#532.
"""
return tuple(reversed(list(self)))
def clone_placeholder(self, placeholder: LayoutPlaceholder) -> None:
"""Add a new placeholder shape based on `placeholder`."""
sp = placeholder.element
ph_type, orient, sz, idx = (sp.ph_type, sp.ph_orient, sp.ph_sz, sp.ph_idx)
id_ = self._next_shape_id
name = self._next_ph_name(ph_type, id_, orient)
self._spTree.add_placeholder(id_, name, ph_type, orient, sz, idx)
def ph_basename(self, ph_type: PP_PLACEHOLDER) -> str:
"""Return the base name for a placeholder of `ph_type` in this shape collection.
There is some variance between slide types, for example a notes slide uses a different
name for the body placeholder, so this method can be overriden by subclasses.
"""
return {
PP_PLACEHOLDER.BITMAP: "ClipArt Placeholder",
PP_PLACEHOLDER.BODY: "Text Placeholder",
PP_PLACEHOLDER.CENTER_TITLE: "Title",
PP_PLACEHOLDER.CHART: "Chart Placeholder",
PP_PLACEHOLDER.DATE: "Date Placeholder",
PP_PLACEHOLDER.FOOTER: "Footer Placeholder",
PP_PLACEHOLDER.HEADER: "Header Placeholder",
PP_PLACEHOLDER.MEDIA_CLIP: "Media Placeholder",
PP_PLACEHOLDER.OBJECT: "Content Placeholder",
PP_PLACEHOLDER.ORG_CHART: "SmartArt Placeholder",
PP_PLACEHOLDER.PICTURE: "Picture Placeholder",
PP_PLACEHOLDER.SLIDE_NUMBER: "Slide Number Placeholder",
PP_PLACEHOLDER.SUBTITLE: "Subtitle",
PP_PLACEHOLDER.TABLE: "Table Placeholder",
PP_PLACEHOLDER.TITLE: "Title",
}[ph_type]
@property
def turbo_add_enabled(self) -> bool:
"""True if "turbo-add" mode is enabled. Read/Write.
EXPERIMENTAL: This feature can radically improve performance when adding large numbers
(hundreds of shapes) to a slide. It works by caching the last shape ID used and
incrementing that value to assign the next shape id. This avoids repeatedly searching all
shape ids in the slide each time a new ID is required.
Performance is not noticeably improved for a slide with a relatively small number of
shapes, but because the search time rises with the square of the shape count, this option
can be useful for optimizing generation of a slide composed of many shapes.
Shape-id collisions can occur (causing a repair error on load) if more than one |Slide|
object is used to interact with the same slide in the presentation. Note that the |Slides|
collection creates a new |Slide| object each time a slide is accessed (e.g. `slide =
prs.slides[0]`, so you must be careful to limit use to a single |Slide| object.
"""
return self._cached_max_shape_id is not None
@turbo_add_enabled.setter
def turbo_add_enabled(self, value: bool):
enable = bool(value)
self._cached_max_shape_id = self._spTree.max_shape_id if enable else None
@staticmethod
def _is_member_elm(shape_elm: ShapeElement) -> bool:
"""Return true if `shape_elm` represents a member of this collection, False otherwise."""
return True
def _iter_member_elms(self) -> Iterator[ShapeElement]:
"""Generate each child of the `p:spTree` element that corresponds to a shape.
Items appear in XML document order.
"""
for shape_elm in self._spTree.iter_shape_elms():
if self._is_member_elm(shape_elm):
yield shape_elm
def _next_ph_name(self, ph_type: PP_PLACEHOLDER, id: int, orient: str) -> str:
"""Next unique placeholder name for placeholder shape of type `ph_type`.
Usually will be standard placeholder root name suffixed with id-1, e.g.
_next_ph_name(ST_PlaceholderType.TBL, 4, 'horz') ==> 'Table Placeholder 3'. The number is
incremented as necessary to make the name unique within the collection. If `orient` is
`'vert'`, the placeholder name is prefixed with `'Vertical '`.
"""
basename = self.ph_basename(ph_type)
# prefix rootname with 'Vertical ' if orient is 'vert'
if orient == ST_Direction.VERT:
basename = "Vertical %s" % basename
# increment numpart as necessary to make name unique
numpart = id - 1
names = self._spTree.xpath("//p:cNvPr/@name")
while True:
name = "%s %d" % (basename, numpart)
if name not in names:
break
numpart += 1
return name
@property
def _next_shape_id(self) -> int:
"""Return a unique shape id suitable for use with a new shape.
The returned id is 1 greater than the maximum shape id used so far. In practice, the
minimum id is 2 because the spTree element is always assigned id="1".
"""
# ---presence of cached-max-shape-id indicates turbo mode is on---
if self._cached_max_shape_id is not None:
self._cached_max_shape_id += 1
return self._cached_max_shape_id
return self._spTree.max_shape_id + 1
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
return BaseShapeFactory(shape_elm, self)
class _BaseGroupShapes(_BaseShapes):
"""Base class for shape-trees that can add shapes."""
part: SlidePart # pyright: ignore[reportIncompatibleMethodOverride]
_element: CT_GroupShape
def __init__(self, grpSp: CT_GroupShape, parent: ProvidesPart):
super(_BaseGroupShapes, self).__init__(grpSp, parent)
self._grpSp = grpSp
def add_chart(
self,
chart_type: XL_CHART_TYPE,
x: Length,
y: Length,
cx: Length,
cy: Length,
chart_data: ChartData,
) -> Chart:
"""Add a new chart of `chart_type` to the slide.
The chart is positioned at (`x`, `y`), has size (`cx`, `cy`), and depicts `chart_data`.
`chart_type` is one of the :ref:`XlChartType` enumeration values. `chart_data` is a
|ChartData| object populated with the categories and series values for the chart.
Note that a |GraphicFrame| shape object is returned, not the |Chart| object contained in
that graphic frame shape. The chart object may be accessed using the :attr:`chart`
property of the returned |GraphicFrame| object.
"""
rId = self.part.add_chart_part(chart_type, chart_data)
graphicFrame = self._add_chart_graphicFrame(rId, x, y, cx, cy)
self._recalculate_extents()
return cast("Chart", self._shape_factory(graphicFrame))
def add_connector(
self,
connector_type: MSO_CONNECTOR_TYPE,
begin_x: Length,
begin_y: Length,
end_x: Length,
end_y: Length,
) -> Connector:
"""Add a newly created connector shape to the end of this shape tree.
`connector_type` is a member of the :ref:`MsoConnectorType` enumeration and the end-point
values are specified as EMU values. The returned connector is of type `connector_type` and
has begin and end points as specified.
"""
cxnSp = self._add_cxnSp(connector_type, begin_x, begin_y, end_x, end_y)
self._recalculate_extents()
return cast(Connector, self._shape_factory(cxnSp))
def add_group_shape(self, shapes: Iterable[BaseShape] = ()) -> GroupShape:
"""Return a |GroupShape| object newly appended to this shape tree.
The group shape is empty and must be populated with shapes using methods on its shape
tree, available on its `.shapes` property. The position and extents of the group shape are
determined by the shapes it contains; its position and extents are recalculated each time
a shape is added to it.
"""
shapes = tuple(shapes)
grpSp = self._element.add_grpSp()
for shape in shapes:
grpSp.insert_element_before(
shape._element,
"p:extLst", # pyright: ignore[reportPrivateUsage]
)
if shapes:
grpSp.recalculate_extents()
return cast(GroupShape, self._shape_factory(grpSp))
def add_ole_object(
self,
object_file: str | IO[bytes],
prog_id: str,
left: Length,
top: Length,
width: Length | None = None,
height: Length | None = None,
icon_file: str | IO[bytes] | None = None,
icon_width: Length | None = None,
icon_height: Length | None = None,
) -> GraphicFrame:
"""Return newly-created GraphicFrame shape embedding `object_file`.
The returned graphic-frame shape contains `object_file` as an embedded OLE object. It is
displayed as an icon at `left`, `top` with size `width`, `height`. `width` and `height`
may be omitted when `prog_id` is a member of `PROG_ID`, in which case the default icon
size is used. This is advised for best appearance where applicable because it avoids an
icon with a "stretched" appearance.
`object_file` may either be a str path to a file or file-like object (such as
`io.BytesIO`) containing the bytes of the object to be embedded (such as an Excel file).
`prog_id` can be either a member of `pptx.enum.shapes.PROG_ID` or a str value like
`"Adobe.Exchange.7"` determined by inspecting the XML generated by PowerPoint for an
object of the desired type.
`icon_file` may either be a str path to an image file or a file-like object containing the
image. The image provided will be displayed in lieu of the OLE object; double-clicking on
the image opens the object (subject to operating-system limitations). The image file can
be any supported image file. Those produced by PowerPoint itself are generally EMF and can
be harvested from a PPTX package that embeds such an object. PNG and JPG also work fine.
`icon_width` and `icon_height` are `Length` values (e.g. Emu() or Inches()) that describe
the size of the icon image within the shape. These should be omitted unless a custom
`icon_file` is provided. The dimensions must be discovered by inspecting the XML.
Automatic resizing of the OLE-object shape can occur when the icon is double-clicked if
these values are not as set by PowerPoint. This behavior may only manifest in the Windows
version of PowerPoint.
"""
graphicFrame = _OleObjectElementCreator.graphicFrame(
self,
self._next_shape_id,
object_file,
prog_id,
left,
top,
width,
height,
icon_file,
icon_width,
icon_height,
)
self._spTree.append(graphicFrame)
self._recalculate_extents()
return cast(GraphicFrame, self._shape_factory(graphicFrame))
def add_picture(
self,
image_file: str | IO[bytes],
left: Length,
top: Length,
width: Length | None = None,
height: Length | None = None,
) -> Picture:
"""Add picture shape displaying image in `image_file`.
`image_file` can be either a path to a file (a string) or a file-like object. The picture
is positioned with its top-left corner at (`top`, `left`). If `width` and `height` are
both |None|, the native size of the image is used. If only one of `width` or `height` is
used, the unspecified dimension is calculated to preserve the aspect ratio of the image.
If both are specified, the picture is stretched to fit, without regard to its native
aspect ratio.
"""
image_part, rId = self.part.get_or_add_image_part(image_file)
pic = self._add_pic_from_image_part(image_part, rId, left, top, width, height)
self._recalculate_extents()
return cast(Picture, self._shape_factory(pic))
def add_shape(
self, autoshape_type_id: MSO_SHAPE, left: Length, top: Length, width: Length, height: Length
) -> Shape:
"""Return new |Shape| object appended to this shape tree.
`autoshape_type_id` is a member of :ref:`MsoAutoShapeType` e.g. `MSO_SHAPE.RECTANGLE`
specifying the type of shape to be added. The remaining arguments specify the new shape's
position and size.
"""
autoshape_type = AutoShapeType(autoshape_type_id)
sp = self._add_sp(autoshape_type, left, top, width, height)
self._recalculate_extents()
return cast(Shape, self._shape_factory(sp))
def add_textbox(self, left: Length, top: Length, width: Length, height: Length) -> Shape:
"""Return newly added text box shape appended to this shape tree.
The text box is of the specified size, located at the specified position on the slide.
"""
sp = self._add_textbox_sp(left, top, width, height)
self._recalculate_extents()
return cast(Shape, self._shape_factory(sp))
def build_freeform(
self, start_x: float = 0, start_y: float = 0, scale: tuple[float, float] | float = 1.0
) -> FreeformBuilder:
"""Return |FreeformBuilder| object to specify a freeform shape.
The optional `start_x` and `start_y` arguments specify the starting pen position in local
coordinates. They will be rounded to the nearest integer before use and each default to
zero.
The optional `scale` argument specifies the size of local coordinates proportional to
slide coordinates (EMU). If the vertical scale is different than the horizontal scale
(local coordinate units are "rectangular"), a pair of numeric values can be provided as
the `scale` argument, e.g. `scale=(1.0, 2.0)`. In this case the first number is
interpreted as the horizontal (X) scale and the second as the vertical (Y) scale.
A convenient method for calculating scale is to divide a |Length| object by an equivalent
count of local coordinate units, e.g. `scale = Inches(1)/1000` for 1000 local units per
inch.
"""
x_scale, y_scale = scale if isinstance(scale, tuple) else (scale, scale)
return FreeformBuilder.new(self, start_x, start_y, x_scale, y_scale)
def index(self, shape: BaseShape) -> int:
"""Return the index of `shape` in this sequence.
Raises |ValueError| if `shape` is not in the collection.
"""
shape_elms = list(self._element.iter_shape_elms())
return shape_elms.index(shape.element)
def _add_chart_graphicFrame(
self, rId: str, x: Length, y: Length, cx: Length, cy: Length
) -> CT_GraphicalObjectFrame:
"""Return new `p:graphicFrame` element appended to this shape tree.
The `p:graphicFrame` element has the specified position and size and refers to the chart
part identified by `rId`.
"""
shape_id = self._next_shape_id
name = "Chart %d" % (shape_id - 1)
graphicFrame = CT_GraphicalObjectFrame.new_chart_graphicFrame(
shape_id, name, rId, x, y, cx, cy
)
self._spTree.append(graphicFrame)
return graphicFrame
def _add_cxnSp(
self,
connector_type: MSO_CONNECTOR_TYPE,
begin_x: Length,
begin_y: Length,
end_x: Length,
end_y: Length,
) -> CT_Connector:
"""Return a newly-added `p:cxnSp` element as specified.
The `p:cxnSp` element is for a connector of `connector_type` beginning at (`begin_x`,
`begin_y`) and extending to (`end_x`, `end_y`).
"""
id_ = self._next_shape_id
name = "Connector %d" % (id_ - 1)
flipH, flipV = begin_x > end_x, begin_y > end_y
x, y = min(begin_x, end_x), min(begin_y, end_y)
cx, cy = abs(end_x - begin_x), abs(end_y - begin_y)
return self._element.add_cxnSp(id_, name, connector_type, x, y, cx, cy, flipH, flipV)
def _add_pic_from_image_part(
self,
image_part: ImagePart,
rId: str,
x: Length,
y: Length,
cx: Length | None,
cy: Length | None,
) -> CT_Picture:
"""Return a newly appended `p:pic` element as specified.
The `p:pic` element displays the image in `image_part` with size and position specified by
`x`, `y`, `cx`, and `cy`. The element is appended to the shape tree, causing it to be
displayed first in z-order on the slide.
"""
id_ = self._next_shape_id
scaled_cx, scaled_cy = image_part.scale(cx, cy)
name = "Picture %d" % (id_ - 1)
desc = image_part.desc
pic = self._grpSp.add_pic(id_, name, desc, rId, x, y, scaled_cx, scaled_cy)
return pic
def _add_sp(
self, autoshape_type: AutoShapeType, x: Length, y: Length, cx: Length, cy: Length
) -> CT_Shape:
"""Return newly-added `p:sp` element as specified.
`p:sp` element is of `autoshape_type` at position (`x`, `y`) and of size (`cx`, `cy`).
"""
id_ = self._next_shape_id
name = "%s %d" % (autoshape_type.basename, id_ - 1)
sp = self._grpSp.add_autoshape(id_, name, autoshape_type.prst, x, y, cx, cy)
return sp
def _add_textbox_sp(self, x: Length, y: Length, cx: Length, cy: Length) -> CT_Shape:
"""Return newly-appended textbox `p:sp` element.
Element has position (`x`, `y`) and size (`cx`, `cy`).
"""
id_ = self._next_shape_id
name = "TextBox %d" % (id_ - 1)
sp = self._spTree.add_textbox(id_, name, x, y, cx, cy)
return sp
def _recalculate_extents(self) -> None:
"""Adjust position and size to incorporate all contained shapes.
This would typically be called when a contained shape is added, removed, or its position
or size updated.
"""
# ---default behavior is to do nothing, GroupShapes overrides to
# produce the distinctive behavior of groups and subgroups.---
pass
class GroupShapes(_BaseGroupShapes):
"""The sequence of child shapes belonging to a group shape.
Note that this collection can itself contain a group shape, making this part of a recursive,
tree data structure (acyclic graph).
"""
def _recalculate_extents(self) -> None:
"""Adjust position and size to incorporate all contained shapes.
This would typically be called when a contained shape is added, removed, or its position
or size updated.
"""
self._grpSp.recalculate_extents()
class SlideShapes(_BaseGroupShapes):
"""Sequence of shapes appearing on a slide.
The first shape in the sequence is the backmost in z-order and the last shape is topmost.
Supports indexed access, len(), index(), and iteration.
"""
parent: Slide # pyright: ignore[reportIncompatibleMethodOverride]
def add_movie(
self,
movie_file: str | IO[bytes],
left: Length,
top: Length,
width: Length,
height: Length,
poster_frame_image: str | IO[bytes] | None = None,
mime_type: str = CT.VIDEO,
) -> GraphicFrame:
"""Return newly added movie shape displaying video in `movie_file`.
**EXPERIMENTAL.** This method has important limitations:
* The size must be specified; no auto-scaling such as that provided by :meth:`add_picture`
is performed.
* The MIME type of the video file should be specified, e.g. 'video/mp4'. The provided
video file is not interrogated for its type. The MIME type `video/unknown` is used by
default (and works fine in tests as of this writing).
* A poster frame image must be provided, it cannot be automatically extracted from the
video file. If no poster frame is provided, the default "media loudspeaker" image will
be used.
Return a newly added movie shape to the slide, positioned at (`left`, `top`), having size
(`width`, `height`), and containing `movie_file`. Before the video is started,
`poster_frame_image` is displayed as a placeholder for the video.
"""
movie_pic = _MoviePicElementCreator.new_movie_pic(
self,
self._next_shape_id,
movie_file,
left,
top,
width,
height,
poster_frame_image,
mime_type,
)
self._spTree.append(movie_pic)
self._add_video_timing(movie_pic)
return cast(GraphicFrame, self._shape_factory(movie_pic))
def add_table(
self, rows: int, cols: int, left: Length, top: Length, width: Length, height: Length
) -> GraphicFrame:
"""Add a |GraphicFrame| object containing a table.
The table has the specified number of `rows` and `cols` and the specified position and
size. `width` is evenly distributed between the columns of the new table. Likewise,
`height` is evenly distributed between the rows. Note that the `.table` property on the
returned |GraphicFrame| shape must be used to access the enclosed |Table| object.
"""
graphicFrame = self._add_graphicFrame_containing_table(rows, cols, left, top, width, height)
return cast(GraphicFrame, self._shape_factory(graphicFrame))
def clone_layout_placeholders(self, slide_layout: SlideLayout) -> None:
"""Add placeholder shapes based on those in `slide_layout`.
Z-order of placeholders is preserved. Latent placeholders (date, slide number, and footer)
are not cloned.
"""
for placeholder in slide_layout.iter_cloneable_placeholders():
self.clone_placeholder(placeholder)
@property
def placeholders(self) -> SlidePlaceholders:
"""Sequence of placeholder shapes in this slide."""
return self.parent.placeholders
@property
def title(self) -> Shape | None:
"""The title placeholder shape on the slide.
|None| if the slide has no title placeholder.
"""
for elm in self._spTree.iter_ph_elms():
if elm.ph_idx == 0:
return cast(Shape, self._shape_factory(elm))
return None
@property
def reading_order(self) -> tuple[BaseShape, ...]:
"""Sequence of shapes in the order screen readers will narrate them.
Reading order on a slide that does not declare an explicit
``<p:tabLst>`` is the document order of children under
``<p:spTree>`` — i.e. the same order as iteration over
:class:`SlideShapes`. Returned as a tuple so callers can compare
against, slice, or index without affecting the underlying XML.
Assigning a reordered sequence reorders the underlying
``<p:spTree>`` children to match. The assigned sequence MUST be
a permutation of this slide's existing shapes — same set, same
length. Raises |ValueError| otherwise.
"""
return tuple(self)
@reading_order.setter
def reading_order(self, new_order):
"""Reorder the slide's shape tree to match `new_order` (a permutation)."""
new_list = list(new_order)
existing = list(self)
if len(new_list) != len(existing):
raise ValueError(
"reading_order must be a permutation of slide.shapes "
"(got %d items, expected %d)" % (len(new_list), len(existing))
)
existing_elements = {s._element for s in existing}
new_elements = [s._element for s in new_list]
if set(new_elements) != existing_elements:
raise ValueError("reading_order must contain exactly the slide's existing shapes")
# ---reorder by removing-then-appending in new order. Children before the
# first shape (e.g. nvGrpSpPr, grpSpPr) remain in place because we only
# move the shape elements themselves.
for elm in new_elements:
self._spTree.remove(elm)
for elm in new_elements:
self._spTree.append(elm)
def accessibility_issues(self) -> list[BaseShape]:
"""Return shapes on this slide that fail basic accessibility lint.
A shape is flagged when it carries no alt text (neither
``alt_text`` nor ``alt_title`` is set) AND is not marked
decorative (``is_decorative`` is False). Returned shapes are
ordered by reading order so callers can iterate top-down.
This is a basic Section 508 / WCAG style check — adding alt text
to every flagged shape, or marking it decorative, brings a slide
to a baseline level of screen-reader friendliness. It does not
cover every accessibility concern (color contrast, font size,
complex tab order, etc.) — treat it as a fast first-pass.
"""
issues: list[BaseShape] = []
for shape in self:
try:
if shape.is_decorative:
continue
if shape.alt_text or shape.alt_title:
continue
except (AttributeError, TypeError):
# ---accessibility properties live on _BaseShape; if a non-shape
# sneaks into the iter (shouldn't, but guard) it cannot be
# flagged.
continue
issues.append(shape)
return issues
def _add_graphicFrame_containing_table(
self, rows: int, cols: int, x: Length, y: Length, cx: Length, cy: Length
) -> CT_GraphicalObjectFrame:
"""Return a newly added `p:graphicFrame` element containing a table as specified."""
_id = self._next_shape_id
name = "Table %d" % (_id - 1)
graphicFrame = self._spTree.add_table(_id, name, rows, cols, x, y, cx, cy)
return graphicFrame
def _add_video_timing(self, pic: CT_Picture) -> None:
"""Add a `p:video` element under `p:sld/p:timing`.
The element will refer to the specified `pic` element by its shape id, and cause the video
play controls to appear for that video.
"""
sld = self._spTree.xpath("/p:sld")[0]
childTnLst = sld.get_or_add_childTnLst()
childTnLst.add_video(pic.shape_id)
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
return SlideShapeFactory(shape_elm, self)
class LayoutShapes(_BaseShapes):
"""Sequence of shapes appearing on a slide layout.
The first shape in the sequence is the backmost in z-order and the last shape is topmost.
Supports indexed access, len(), index(), and iteration.
"""
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
return _LayoutShapeFactory(shape_elm, self)
class MasterShapes(_BaseShapes):
"""Sequence of shapes appearing on a slide master.
The first shape in the sequence is the backmost in z-order and the last shape is topmost.
Supports indexed access, len(), and iteration.
"""
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
return _MasterShapeFactory(shape_elm, self)
class NotesSlideShapes(_BaseShapes):
"""Sequence of shapes appearing on a notes slide.
The first shape in the sequence is the backmost in z-order and the last shape is topmost.
Supports indexed access, len(), index(), and iteration.
"""
def ph_basename(self, ph_type: PP_PLACEHOLDER) -> str:
"""Return the base name for a placeholder of `ph_type` in this shape collection.
A notes slide uses a different name for the body placeholder and has some unique
placeholder types, so this method overrides the default in the base class.
"""
return {
PP_PLACEHOLDER.BODY: "Notes Placeholder",
PP_PLACEHOLDER.DATE: "Date Placeholder",
PP_PLACEHOLDER.FOOTER: "Footer Placeholder",
PP_PLACEHOLDER.HEADER: "Header Placeholder",
PP_PLACEHOLDER.SLIDE_IMAGE: "Slide Image Placeholder",
PP_PLACEHOLDER.SLIDE_NUMBER: "Slide Number Placeholder",
}[ph_type]
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
"""Return appropriate shape object for `shape_elm` appearing on a notes slide."""
return _NotesSlideShapeFactory(shape_elm, self)
class BasePlaceholders(_BaseShapes):
"""Base class for placeholder collections.
Subclasses differentiate behaviors for a master, layout, and slide. By default, placeholder
shapes are constructed using |BaseShapeFactory|. Subclasses should override
:method:`_shape_factory` to use custom placeholder classes.
"""
@staticmethod
def _is_member_elm(shape_elm: ShapeElement) -> bool:
"""True if `shape_elm` is a placeholder shape, False otherwise."""
return shape_elm.has_ph_elm
class LayoutPlaceholders(BasePlaceholders):
"""Sequence of |LayoutPlaceholder| instance for each placeholder shape on a slide layout."""
__iter__: Callable[ # pyright: ignore[reportIncompatibleMethodOverride]
[], Iterator[LayoutPlaceholder]
]
def get(self, idx: int, default: LayoutPlaceholder | None = None) -> LayoutPlaceholder | None:
"""The first placeholder shape with matching `idx` value, or `default` if not found."""
for placeholder in self:
if placeholder.element.ph_idx == idx:
return placeholder
return default
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
return _LayoutShapeFactory(shape_elm, self)
class MasterPlaceholders(BasePlaceholders):
"""Sequence of MasterPlaceholder representing the placeholder shapes on a slide master."""
__iter__: Callable[ # pyright: ignore[reportIncompatibleMethodOverride]
[], Iterator[MasterPlaceholder]
]
def get(self, ph_type: PP_PLACEHOLDER, default: MasterPlaceholder | None = None):
"""Return the first placeholder shape with type `ph_type` (e.g. 'body').
Returns `default` if no such placeholder shape is present in the collection.
"""
for placeholder in self:
if placeholder.ph_type == ph_type:
return placeholder
return default
def _shape_factory( # pyright: ignore[reportIncompatibleMethodOverride]
self, placeholder_elm: CT_Shape
) -> MasterPlaceholder:
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
return cast(MasterPlaceholder, _MasterShapeFactory(placeholder_elm, self))
class NotesSlidePlaceholders(MasterPlaceholders):
"""Sequence of placeholder shapes on a notes slide."""
__iter__: Callable[ # pyright: ignore[reportIncompatibleMethodOverride]
[], Iterator[NotesSlidePlaceholder]
]
def _shape_factory( # pyright: ignore[reportIncompatibleMethodOverride]
self, placeholder_elm: CT_Shape
) -> NotesSlidePlaceholder:
"""Return an instance of the appropriate placeholder proxy class for `placeholder_elm`."""
return cast(NotesSlidePlaceholder, _NotesSlideShapeFactory(placeholder_elm, self))
class SlidePlaceholders(ParentedElementProxy):
"""Collection of placeholder shapes on a slide.
Supports iteration, :func:`len`, and dictionary-style lookup by both the
`idx` value (int) and the placeholder ``.name`` (str).
"""
_element: CT_GroupShape
def __getitem__(self, key: int | str):
"""Access placeholder shape by `idx` value (int) or `.name` (str).
Note that while this looks like list access, integer ``key`` is a
dictionary key against the placeholder's ``ph_idx`` (NOT a sequence
index) and will raise |KeyError| if no placeholder with that idx
is in the collection. String ``key`` looks up by ``.name`` and
raises |KeyError| on miss. ``bool`` keys are rejected (|TypeError|)
— they're a subclass of ``int`` so would otherwise silently resolve
to a `ph_idx == 0/1` lookup, almost certainly unintended.
Closes scanny/python-pptx#800.
"""
if isinstance(key, bool):
raise TypeError("placeholder key must be int or str, got bool")
if isinstance(key, str):
for ph in self:
if ph.name == key:
return ph
raise KeyError("no placeholder named %r in this collection" % key)
for e in self._element.iter_ph_elms():
if e.ph_idx == key:
return SlideShapeFactory(e, self)
raise KeyError("no placeholder on this slide with idx == %d" % key)
def __contains__(self, key: object) -> bool:
"""Mapping-like membership: `"Title 1" in placeholders` checks names.
- String key: True when any placeholder's ``.name`` matches.
- Integer key: True when a placeholder with that ``ph_idx`` exists.
- ``bool`` and other key types return False (bools rejected for the
same reason `__getitem__` rejects them).
"""
if isinstance(key, bool):
return False
if isinstance(key, str):
return any(ph.name == key for ph in self)
if isinstance(key, int):
return any(e.ph_idx == key for e in self._element.iter_ph_elms())
return False
def keys(self) -> list[str]:
"""List of every placeholder's ``.name`` in iteration order."""
return [ph.name for ph in self]
def __iter__(self):
"""Generate placeholder shapes in `idx` order."""
ph_elms = sorted(self._element.iter_ph_elms(), key=lambda e: e.ph_idx)
return (SlideShapeFactory(e, self) for e in ph_elms)
def __len__(self) -> int:
"""Return count of placeholder shapes."""
return len(list(self._element.iter_ph_elms()))