python-pptx2 2.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/_slide_importer.py
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Slide import machinery for Presentation.import_slide().
|
|
2
|
+
|
|
3
|
+
Copies a slide from one |Presentation| into another, rewriting part-names
|
|
4
|
+
and relationship targets so the imported slide is indistinguishable from a
|
|
5
|
+
natively authored one.
|
|
6
|
+
|
|
7
|
+
The public entry point is :func:`import_slide`. Everything else is an
|
|
8
|
+
implementation detail.
|
|
9
|
+
|
|
10
|
+
Supported ``merge_master`` values:
|
|
11
|
+
|
|
12
|
+
``'dedupe'``
|
|
13
|
+
Reuse an existing destination master when its (normalised) XML fingerprint
|
|
14
|
+
matches the source master. Otherwise clone the master.
|
|
15
|
+
|
|
16
|
+
``'clone'``
|
|
17
|
+
Always clone the master and its layout/theme parts, even when the
|
|
18
|
+
destination already has an identical master.
|
|
19
|
+
|
|
20
|
+
Copied content
|
|
21
|
+
--------------
|
|
22
|
+
The following slide-level parts are copied:
|
|
23
|
+
|
|
24
|
+
* The slide itself.
|
|
25
|
+
* Its notes slide (if any).
|
|
26
|
+
* All image / media / chart / OLE-object / SmartArt-diagram / video parts
|
|
27
|
+
reachable from the slide.
|
|
28
|
+
* The slide layout (always cloned — layouts belong to a specific master).
|
|
29
|
+
* The slide master and its theme (deduped or cloned, see above).
|
|
30
|
+
|
|
31
|
+
The following are intentionally **not** deep-copied:
|
|
32
|
+
|
|
33
|
+
* The notes master — the copied notes slide is re-linked to the destination's
|
|
34
|
+
own notes master; the source's is cloned only when the destination has none.
|
|
35
|
+
* The handout master.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
from copy import deepcopy
|
|
42
|
+
from typing import TYPE_CHECKING, Literal
|
|
43
|
+
|
|
44
|
+
from pptx2.opc.constants import RELATIONSHIP_TYPE as RT
|
|
45
|
+
from pptx2.opc.package import Part, PartFactory, XmlPart
|
|
46
|
+
from pptx2.opc.packuri import PackURI
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from pptx2.opc.package import _Relationships # pyright: ignore[reportPrivateUsage]
|
|
50
|
+
from pptx2.package import Package
|
|
51
|
+
from pptx2.parts.presentation import PresentationPart
|
|
52
|
+
from pptx2.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart
|
|
53
|
+
from pptx2.slide import Slide
|
|
54
|
+
|
|
55
|
+
MergeMaster = Literal["dedupe", "clone"]
|
|
56
|
+
|
|
57
|
+
# Relationship types whose target parts belong to the master / layout hierarchy
|
|
58
|
+
# and are therefore handled separately from the general part graph copy.
|
|
59
|
+
_MASTER_HIERARCHY_RELTYPES = frozenset(
|
|
60
|
+
{
|
|
61
|
+
RT.SLIDE_LAYOUT,
|
|
62
|
+
RT.SLIDE_MASTER,
|
|
63
|
+
RT.THEME,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Relationship types for parts that travel with the slide (non-master deps)
|
|
68
|
+
_NOTES_MASTER_RELTYPE = RT.NOTES_MASTER
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def import_slide(
|
|
72
|
+
src_slide_part: SlidePart,
|
|
73
|
+
dst_prs_part: PresentationPart,
|
|
74
|
+
merge_master: MergeMaster = "dedupe",
|
|
75
|
+
) -> Slide:
|
|
76
|
+
"""Copy *src_slide_part* into *dst_prs_part* and return the new |Slide|.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
src_slide_part:
|
|
81
|
+
The :class:`~pptx2.parts.slide.SlidePart` from the source presentation.
|
|
82
|
+
dst_prs_part:
|
|
83
|
+
The :class:`~pptx2.parts.presentation.PresentationPart` of the target.
|
|
84
|
+
merge_master:
|
|
85
|
+
``'dedupe'`` (default) or ``'clone'`` — controls master handling.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
Slide
|
|
90
|
+
The newly imported :class:`~pptx2.slide.Slide` object.
|
|
91
|
+
"""
|
|
92
|
+
importer = _SlideImporter(src_slide_part, dst_prs_part, merge_master)
|
|
93
|
+
return importer.run()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Internal implementation
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _SlideImporter:
|
|
102
|
+
"""Stateful helper that performs the import operation."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
src_slide_part: SlidePart,
|
|
107
|
+
dst_prs_part: PresentationPart,
|
|
108
|
+
merge_master: MergeMaster,
|
|
109
|
+
) -> None:
|
|
110
|
+
self._src_slide_part = src_slide_part
|
|
111
|
+
self._dst_prs_part = dst_prs_part
|
|
112
|
+
self._merge_master = merge_master
|
|
113
|
+
self._dst_package: Package = dst_prs_part.package # type: ignore[assignment]
|
|
114
|
+
# Mapping: source Part → copied destination Part
|
|
115
|
+
self._part_map: dict[Part, Part] = {}
|
|
116
|
+
# Partnames already picked in this import run (but not yet in the package graph)
|
|
117
|
+
self._reserved_partnames: set[str] = set()
|
|
118
|
+
|
|
119
|
+
def run(self) -> Slide:
|
|
120
|
+
"""Execute the import and return the new Slide."""
|
|
121
|
+
dst_layout_part = self._resolve_layout()
|
|
122
|
+
dst_slide_part = self._copy_slide(dst_layout_part)
|
|
123
|
+
self._register_slide(dst_slide_part)
|
|
124
|
+
return dst_slide_part.slide
|
|
125
|
+
|
|
126
|
+
def _next_partname(self, tmpl: str) -> PackURI:
|
|
127
|
+
"""Return the next non-colliding partname for *tmpl*, accounting for parts
|
|
128
|
+
not yet in the package graph that have already been reserved in this run."""
|
|
129
|
+
prefix = tmpl[: (tmpl % 42).find("42")]
|
|
130
|
+
existing = {
|
|
131
|
+
p.partname for p in self._dst_package.iter_parts()
|
|
132
|
+
if p.partname.startswith(prefix)
|
|
133
|
+
}
|
|
134
|
+
taken = existing | {pn for pn in self._reserved_partnames if pn.startswith(prefix)}
|
|
135
|
+
n = 1
|
|
136
|
+
while True:
|
|
137
|
+
candidate = tmpl % n
|
|
138
|
+
if candidate not in taken:
|
|
139
|
+
self._reserved_partnames.add(candidate)
|
|
140
|
+
return PackURI(candidate)
|
|
141
|
+
n += 1
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# Master / layout resolution
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _resolve_layout(self) -> SlideLayoutPart:
|
|
148
|
+
"""Return the destination layout part for the imported slide.
|
|
149
|
+
|
|
150
|
+
Either reuses an existing destination master (dedupe) or clones a new
|
|
151
|
+
one (clone or dedupe miss).
|
|
152
|
+
"""
|
|
153
|
+
src_layout_part: SlideLayoutPart = self._src_slide_part.part_related_by(RT.SLIDE_LAYOUT) # type: ignore[assignment]
|
|
154
|
+
src_master_part: SlideMasterPart = src_layout_part.part_related_by(RT.SLIDE_MASTER) # type: ignore[assignment]
|
|
155
|
+
|
|
156
|
+
if self._merge_master == "dedupe":
|
|
157
|
+
existing = self._find_matching_master(src_master_part)
|
|
158
|
+
if existing is not None:
|
|
159
|
+
return self._find_or_clone_layout_in_master(src_layout_part, existing)
|
|
160
|
+
|
|
161
|
+
# Clone master + theme + all layouts
|
|
162
|
+
return self._clone_master_with_layout(src_master_part, src_layout_part)
|
|
163
|
+
|
|
164
|
+
def _find_matching_master(self, src_master_part: SlideMasterPart) -> SlideMasterPart | None:
|
|
165
|
+
"""Return a destination master part whose fingerprint matches src_master_part, or None."""
|
|
166
|
+
src_fp = _master_fingerprint(src_master_part)
|
|
167
|
+
for dst_master_part in self._iter_dst_masters():
|
|
168
|
+
if _master_fingerprint(dst_master_part) == src_fp:
|
|
169
|
+
return dst_master_part
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def _iter_dst_masters(self): # type: ignore[return]
|
|
173
|
+
"""Yield each SlideMasterPart already in the destination presentation."""
|
|
174
|
+
prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
|
|
175
|
+
if prs_element.sldMasterIdLst is None:
|
|
176
|
+
return
|
|
177
|
+
for entry in prs_element.sldMasterIdLst.sldMasterId_lst:
|
|
178
|
+
yield self._dst_prs_part.related_part(entry.rId)
|
|
179
|
+
|
|
180
|
+
def _find_or_clone_layout_in_master(
|
|
181
|
+
self, src_layout_part: SlideLayoutPart, dst_master_part: SlideMasterPart
|
|
182
|
+
) -> SlideLayoutPart:
|
|
183
|
+
"""Return the layout in *dst_master_part* that best matches *src_layout_part*.
|
|
184
|
+
|
|
185
|
+
Matching order:
|
|
186
|
+
1. Same ``<p:cSld name="…">``
|
|
187
|
+
2. Same layout ``type`` attribute
|
|
188
|
+
3. Fall back to first layout in master
|
|
189
|
+
|
|
190
|
+
If no layout matches either criterion, the source layout is cloned
|
|
191
|
+
into the destination master.
|
|
192
|
+
"""
|
|
193
|
+
src_name = src_layout_part._element.cSld.name # pyright: ignore[reportPrivateUsage]
|
|
194
|
+
src_type = src_layout_part._element.get("type") # pyright: ignore[reportPrivateUsage]
|
|
195
|
+
|
|
196
|
+
# Walk existing layouts on the destination master
|
|
197
|
+
from pptx2.opc.constants import RELATIONSHIP_TYPE as RT2
|
|
198
|
+
|
|
199
|
+
layout_candidates: list[SlideLayoutPart] = []
|
|
200
|
+
for rel in dst_master_part.rels.values():
|
|
201
|
+
if rel.is_external or rel.reltype != RT2.SLIDE_LAYOUT:
|
|
202
|
+
continue
|
|
203
|
+
layout_candidates.append(rel.target_part) # type: ignore[arg-type]
|
|
204
|
+
|
|
205
|
+
# Priority 1: name match
|
|
206
|
+
for lp in layout_candidates:
|
|
207
|
+
if lp._element.cSld.name == src_name: # pyright: ignore[reportPrivateUsage]
|
|
208
|
+
return lp # type: ignore[return-value]
|
|
209
|
+
|
|
210
|
+
# Priority 2: type match
|
|
211
|
+
if src_type:
|
|
212
|
+
for lp in layout_candidates:
|
|
213
|
+
if lp._element.get("type") == src_type: # pyright: ignore[reportPrivateUsage]
|
|
214
|
+
return lp # type: ignore[return-value]
|
|
215
|
+
|
|
216
|
+
# Priority 3: clone the layout into the existing master
|
|
217
|
+
return self._clone_layout_into_master(src_layout_part, dst_master_part)
|
|
218
|
+
|
|
219
|
+
def _clone_layout_into_master(
|
|
220
|
+
self, src_layout_part: SlideLayoutPart, dst_master_part: SlideMasterPart
|
|
221
|
+
) -> SlideLayoutPart:
|
|
222
|
+
"""Clone *src_layout_part* and attach it to *dst_master_part*.
|
|
223
|
+
|
|
224
|
+
Copies the layout's own dependencies (background pictures etc.),
|
|
225
|
+
wires the layout ↔ master relationships, and registers the layout in
|
|
226
|
+
the master's `p:sldLayoutIdLst` with a fresh unique id — an
|
|
227
|
+
unregistered layout is invisible to PowerPoint's layout picker and
|
|
228
|
+
leaves the master's id list inconsistent with its relationships.
|
|
229
|
+
"""
|
|
230
|
+
new_partname = self._next_partname("/ppt/slideLayouts/slideLayout%d.xml")
|
|
231
|
+
dst_layout_part = _clone_xml_part(src_layout_part, new_partname, self._dst_package)
|
|
232
|
+
id_map = self._copy_dependencies(src_layout_part, dst_layout_part)
|
|
233
|
+
_remap_rids(dst_layout_part._element, id_map) # pyright: ignore[reportPrivateUsage]
|
|
234
|
+
# Relate layout → master
|
|
235
|
+
dst_layout_part.relate_to(dst_master_part, RT.SLIDE_MASTER)
|
|
236
|
+
# Relate master → layout
|
|
237
|
+
rId = dst_master_part.relate_to(dst_layout_part, RT.SLIDE_LAYOUT)
|
|
238
|
+
# Register in the master's layout-id list
|
|
239
|
+
master_element = dst_master_part._element # pyright: ignore[reportPrivateUsage]
|
|
240
|
+
sldLayoutIdLst = master_element.get_or_add_sldLayoutIdLst()
|
|
241
|
+
entry = sldLayoutIdLst._add_sldLayoutId(rId=rId)
|
|
242
|
+
entry.set("id", str(self._next_hierarchy_id()))
|
|
243
|
+
return dst_layout_part # type: ignore[return-value]
|
|
244
|
+
|
|
245
|
+
def _clone_master_with_layout(
|
|
246
|
+
self, src_master_part: SlideMasterPart, src_layout_part: SlideLayoutPart
|
|
247
|
+
) -> SlideLayoutPart:
|
|
248
|
+
"""Clone the master (+ theme + all of its layouts), register with the destination.
|
|
249
|
+
|
|
250
|
+
Returns the cloned layout corresponding to *src_layout_part*.
|
|
251
|
+
"""
|
|
252
|
+
dst_package = self._dst_package
|
|
253
|
+
|
|
254
|
+
# --- Clone theme ---
|
|
255
|
+
dst_theme_part: Part | None = None
|
|
256
|
+
try:
|
|
257
|
+
src_theme_part = src_master_part.part_related_by(RT.THEME)
|
|
258
|
+
theme_partname = self._next_partname("/ppt/theme/theme%d.xml")
|
|
259
|
+
dst_theme_part = _clone_part(src_theme_part, theme_partname, dst_package)
|
|
260
|
+
except KeyError:
|
|
261
|
+
pass # some masters have no theme
|
|
262
|
+
|
|
263
|
+
# --- Clone master ---
|
|
264
|
+
master_partname = self._next_partname("/ppt/slideMasters/slideMaster%d.xml")
|
|
265
|
+
dst_master_part = _clone_xml_part(src_master_part, master_partname, dst_package)
|
|
266
|
+
if dst_theme_part is not None:
|
|
267
|
+
dst_master_part.relate_to(dst_theme_part, RT.THEME)
|
|
268
|
+
id_map = self._copy_dependencies(src_master_part, dst_master_part)
|
|
269
|
+
_remap_rids(dst_master_part._element, id_map) # pyright: ignore[reportPrivateUsage]
|
|
270
|
+
|
|
271
|
+
# The deep-copied master XML still carries the *source* master's
|
|
272
|
+
# `p:sldLayoutIdLst` — its r:id values point at whatever landed on
|
|
273
|
+
# those rIds in the new rels (the theme, off-by-one layouts), which
|
|
274
|
+
# PowerPoint cannot resolve. Empty it here and rebuild it entry by
|
|
275
|
+
# entry as each layout is cloned below.
|
|
276
|
+
master_element = dst_master_part._element # pyright: ignore[reportPrivateUsage]
|
|
277
|
+
sldLayoutIdLst = master_element.get_or_add_sldLayoutIdLst()
|
|
278
|
+
for stale_entry in list(sldLayoutIdLst):
|
|
279
|
+
sldLayoutIdLst.remove(stale_entry)
|
|
280
|
+
|
|
281
|
+
# --- Register new master with presentation (fresh unique id) ---
|
|
282
|
+
rId = self._dst_prs_part.relate_to(dst_master_part, RT.SLIDE_MASTER)
|
|
283
|
+
prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
|
|
284
|
+
sldMasterIdLst = prs_element.get_or_add_sldMasterIdLst()
|
|
285
|
+
master_entry = sldMasterIdLst._add_sldMasterId(rId=rId) # pyright: ignore[reportAttributeAccessIssue]
|
|
286
|
+
master_entry.set("id", str(self._next_hierarchy_id()))
|
|
287
|
+
|
|
288
|
+
# --- Clone layouts, preserving the source master's layout order ---
|
|
289
|
+
src_to_dst_layout: dict[SlideLayoutPart, SlideLayoutPart] = {}
|
|
290
|
+
for src_lo in self._iter_master_layouts(src_master_part):
|
|
291
|
+
src_to_dst_layout[src_lo] = self._clone_layout_into_master(src_lo, dst_master_part)
|
|
292
|
+
|
|
293
|
+
# Return the cloned version of the source layout
|
|
294
|
+
dst_layout = src_to_dst_layout.get(src_layout_part)
|
|
295
|
+
if dst_layout is None:
|
|
296
|
+
# Fallback: use the first available layout
|
|
297
|
+
dst_layout = next(iter(src_to_dst_layout.values()), None)
|
|
298
|
+
if dst_layout is None:
|
|
299
|
+
# Emergency: clone the layout directly
|
|
300
|
+
dst_layout = self._clone_layout_into_master(src_layout_part, dst_master_part) # type: ignore[assignment]
|
|
301
|
+
return dst_layout # type: ignore[return-value]
|
|
302
|
+
|
|
303
|
+
def _iter_master_layouts(self, master_part: SlideMasterPart) -> list[SlideLayoutPart]:
|
|
304
|
+
"""Return *master_part*'s layout parts, in `p:sldLayoutIdLst` order.
|
|
305
|
+
|
|
306
|
+
Falls back to relationship order for a master whose layout-id list is
|
|
307
|
+
absent or entirely unresolvable.
|
|
308
|
+
"""
|
|
309
|
+
layouts: list[SlideLayoutPart] = []
|
|
310
|
+
sldLayoutIdLst = master_part._element.sldLayoutIdLst # pyright: ignore[reportPrivateUsage]
|
|
311
|
+
if sldLayoutIdLst is not None:
|
|
312
|
+
for entry in sldLayoutIdLst.sldLayoutId_lst:
|
|
313
|
+
try:
|
|
314
|
+
layouts.append(master_part.related_part(entry.rId)) # type: ignore[arg-type]
|
|
315
|
+
except KeyError:
|
|
316
|
+
continue
|
|
317
|
+
if layouts:
|
|
318
|
+
return layouts
|
|
319
|
+
return [
|
|
320
|
+
rel.target_part # type: ignore[misc]
|
|
321
|
+
for rel in master_part.rels.values()
|
|
322
|
+
if not rel.is_external and rel.reltype == RT.SLIDE_LAYOUT
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
def _next_hierarchy_id(self) -> int:
|
|
326
|
+
"""Return a fresh unique id for a `p:sldMasterId` / `p:sldLayoutId` entry.
|
|
327
|
+
|
|
328
|
+
These share one id space starting at 2147483648 (ST_SlideMasterId /
|
|
329
|
+
ST_SlideLayoutId minimum); duplicates across the presentation are a
|
|
330
|
+
repair trigger, so scan every master's layout-id list plus the
|
|
331
|
+
presentation's master-id list for the current maximum.
|
|
332
|
+
"""
|
|
333
|
+
used = [2147483647]
|
|
334
|
+
prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
|
|
335
|
+
used += [int(v) for v in prs_element.xpath("p:sldMasterIdLst/p:sldMasterId/@id")]
|
|
336
|
+
for dst_master_part in self._iter_dst_masters():
|
|
337
|
+
used += [
|
|
338
|
+
int(v)
|
|
339
|
+
for v in dst_master_part._element.xpath( # pyright: ignore[reportPrivateUsage]
|
|
340
|
+
"p:sldLayoutIdLst/p:sldLayoutId/@id"
|
|
341
|
+
)
|
|
342
|
+
]
|
|
343
|
+
return max(used) + 1
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# Slide copy
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def _copy_slide(self, dst_layout_part: SlideLayoutPart) -> SlidePart:
|
|
350
|
+
"""Return a new SlidePart copied from the source slide.
|
|
351
|
+
|
|
352
|
+
All non-master-hierarchy dependencies (images, charts, media, notes, etc.)
|
|
353
|
+
are also copied. The new slide is related to *dst_layout_part*.
|
|
354
|
+
"""
|
|
355
|
+
dst_package = self._dst_package
|
|
356
|
+
new_partname = self._next_partname("/ppt/slides/slide%d.xml")
|
|
357
|
+
dst_slide_part = _clone_xml_part(self._src_slide_part, new_partname, dst_package)
|
|
358
|
+
|
|
359
|
+
# Seed the part map with the slide itself so a copied notes slide's
|
|
360
|
+
# back-reference to its slide resolves to *this* part rather than
|
|
361
|
+
# triggering a second, orphan clone of the whole slide graph.
|
|
362
|
+
self._part_map[self._src_slide_part] = dst_slide_part
|
|
363
|
+
|
|
364
|
+
# Copy all deps except master-hierarchy rels. Track source-rId ->
|
|
365
|
+
# destination-rId so the cloned slide XML's embedded references
|
|
366
|
+
# (r:embed, r:id, …) can be rewritten to the new ids.
|
|
367
|
+
id_map = self._copy_dependencies(self._src_slide_part, dst_slide_part)
|
|
368
|
+
|
|
369
|
+
# Always wire layout relationship; map the source layout rId onto the new
|
|
370
|
+
# one in case the slide XML references it.
|
|
371
|
+
layout_rId = dst_slide_part.relate_to(dst_layout_part, RT.SLIDE_LAYOUT)
|
|
372
|
+
for rel in self._src_slide_part.rels.values():
|
|
373
|
+
if rel.reltype == RT.SLIDE_LAYOUT:
|
|
374
|
+
id_map[rel.rId] = layout_rId
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
_remap_rids(dst_slide_part._element, id_map) # pyright: ignore[reportPrivateUsage]
|
|
378
|
+
return dst_slide_part # type: ignore[return-value]
|
|
379
|
+
|
|
380
|
+
def _copy_dependencies(self, src_part: Part, dst_part: Part) -> dict[str, str]:
|
|
381
|
+
"""Copy *src_part*'s dependencies onto *dst_part*; return the rId map.
|
|
382
|
+
|
|
383
|
+
External relationships are re-created verbatim; master-hierarchy
|
|
384
|
+
relationships (layout / master / theme) are skipped — callers wire
|
|
385
|
+
those explicitly; a notes-master relationship is re-pointed at the
|
|
386
|
+
destination's own notes master (importing the source's only when the
|
|
387
|
+
destination has none). Every other related part is copied
|
|
388
|
+
recursively. The caller is responsible for running the returned
|
|
389
|
+
source-rId → destination-rId map through :func:`_remap_rids`.
|
|
390
|
+
"""
|
|
391
|
+
id_map: dict[str, str] = {}
|
|
392
|
+
for rel in src_part.rels.values():
|
|
393
|
+
if rel.is_external:
|
|
394
|
+
id_map[rel.rId] = dst_part.relate_to(
|
|
395
|
+
rel.target_ref, rel.reltype, is_external=True
|
|
396
|
+
)
|
|
397
|
+
continue
|
|
398
|
+
if rel.reltype in _MASTER_HIERARCHY_RELTYPES:
|
|
399
|
+
continue
|
|
400
|
+
if rel.reltype == _NOTES_MASTER_RELTYPE:
|
|
401
|
+
# Re-point at the destination's own notes master (created
|
|
402
|
+
# from the default template when absent) — a notes slide
|
|
403
|
+
# without its notesSlide→notesMaster relationship risks the
|
|
404
|
+
# repair prompt when notes view is opened or notes printed.
|
|
405
|
+
dst_notes_master = self._dst_prs_part.notes_master_part
|
|
406
|
+
id_map[rel.rId] = dst_part.relate_to(dst_notes_master, RT.NOTES_MASTER)
|
|
407
|
+
continue
|
|
408
|
+
dst_dep = self._copy_part_recursive(rel.target_part)
|
|
409
|
+
id_map[rel.rId] = dst_part.relate_to(dst_dep, rel.reltype)
|
|
410
|
+
return id_map
|
|
411
|
+
|
|
412
|
+
def _copy_part_recursive(self, src_part: Part) -> Part:
|
|
413
|
+
"""Return a copy of *src_part* in the destination package.
|
|
414
|
+
|
|
415
|
+
Recursively copies all related parts (depth-first). Each source part
|
|
416
|
+
is copied at most once; subsequent calls for the same part return the
|
|
417
|
+
already-copied destination counterpart.
|
|
418
|
+
"""
|
|
419
|
+
if src_part in self._part_map:
|
|
420
|
+
return self._part_map[src_part]
|
|
421
|
+
|
|
422
|
+
dst_package = self._dst_package
|
|
423
|
+
new_partname = self._next_partname(_partname_template(src_part.partname))
|
|
424
|
+
dst_part = _clone_part(src_part, new_partname, dst_package)
|
|
425
|
+
self._part_map[src_part] = dst_part
|
|
426
|
+
|
|
427
|
+
id_map = self._copy_dependencies(src_part, dst_part)
|
|
428
|
+
|
|
429
|
+
# Rewrite embedded rId references in cloned XML parts (e.g. a chart's
|
|
430
|
+
# <c:externalData r:id=…> pointing at its embedded workbook).
|
|
431
|
+
if isinstance(dst_part, XmlPart):
|
|
432
|
+
_remap_rids(dst_part._element, id_map) # pyright: ignore[reportPrivateUsage]
|
|
433
|
+
|
|
434
|
+
return dst_part
|
|
435
|
+
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
# Presentation registration
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def _register_slide(self, dst_slide_part: SlidePart) -> None:
|
|
441
|
+
"""Add the new slide part to the destination presentation."""
|
|
442
|
+
rId = self._dst_prs_part.relate_to(dst_slide_part, RT.SLIDE)
|
|
443
|
+
prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
|
|
444
|
+
sldId = prs_element.get_or_add_sldIdLst().add_sldId(rId)
|
|
445
|
+
# A sectioned destination keeps its section list a complete partition
|
|
446
|
+
# — the imported slide lands at the end of the deck, so it joins the
|
|
447
|
+
# final section (same rule as Slides.add_slide).
|
|
448
|
+
sectionLst = getattr(prs_element, "sectionLst", None)
|
|
449
|
+
if sectionLst is not None and sectionLst.section_lst:
|
|
450
|
+
sectionLst.section_lst[-1].add_sldId(sldId.id)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# Part-copy helpers
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _clone_part(src_part: Part, new_partname: PackURI, dst_package: Package) -> Part:
|
|
459
|
+
"""Return a new Part in *dst_package* that is a copy of *src_part*.
|
|
460
|
+
|
|
461
|
+
For XmlParts the XML is re-parsed (no element sharing between packages).
|
|
462
|
+
For binary Parts the blob bytes are shared (immutable).
|
|
463
|
+
"""
|
|
464
|
+
return PartFactory(new_partname, src_part.content_type, dst_package, blob=src_part.blob)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _clone_xml_part(src_part: XmlPart, new_partname: PackURI, dst_package: Package) -> XmlPart:
|
|
468
|
+
"""Return a new XmlPart in *dst_package* that is a copy of *src_part*.
|
|
469
|
+
|
|
470
|
+
The XML element tree is deep-copied so mutations on one part do not affect the other.
|
|
471
|
+
No relationships are copied; callers must add them explicitly.
|
|
472
|
+
"""
|
|
473
|
+
new_element = deepcopy(src_part._element) # pyright: ignore[reportPrivateUsage]
|
|
474
|
+
return src_part.__class__(new_partname, src_part.content_type, dst_package, new_element)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# Relationship-id references embedded in part XML all live in the ``r:``
|
|
478
|
+
# namespace (``r:embed``, ``r:id``, ``r:link``, and the SmartArt
|
|
479
|
+
# ``dgm:relIds`` ``r:dm``/``r:lo``/``r:qs``/``r:cs``). When a part is cloned its
|
|
480
|
+
# relationships are re-created in copy order, so the new rIds rarely match the
|
|
481
|
+
# ones baked into the deep-copied XML — leaving e.g. a picture's
|
|
482
|
+
# ``r:embed="rId2"`` pointing at whatever part happened to land on rId2 in the
|
|
483
|
+
# destination. PowerPoint then can't resolve the image and repairs the deck.
|
|
484
|
+
_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _remap_rids(element, id_map: dict[str, str]) -> None:
|
|
488
|
+
"""Rewrite every ``r:*`` attribute on *element*'s tree through *id_map*.
|
|
489
|
+
|
|
490
|
+
Only relationship-namespace attributes whose value is a known source rId are
|
|
491
|
+
touched, so non-relationship attributes are never disturbed.
|
|
492
|
+
"""
|
|
493
|
+
if not id_map:
|
|
494
|
+
return
|
|
495
|
+
prefix = "{%s}" % _R_NS
|
|
496
|
+
for el in element.iter():
|
|
497
|
+
for name, value in list(el.attrib.items()):
|
|
498
|
+
if name.startswith(prefix) and value in id_map:
|
|
499
|
+
el.set(name, id_map[value])
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _master_fingerprint(master_part: Part) -> bytes:
|
|
503
|
+
"""Return a SHA-256 hash over the normalised master XML + its theme XML.
|
|
504
|
+
|
|
505
|
+
Used for master deduplication: two masters with identical fingerprints are
|
|
506
|
+
considered equivalent for the purposes of ``merge_master='dedupe'``.
|
|
507
|
+
|
|
508
|
+
The hash must be *stable across packages*: cloning a master rebuilds its
|
|
509
|
+
`p:sldLayoutIdLst` (fresh rIds and unique ids) and remaps any `r:embed`
|
|
510
|
+
references in its body, so those package-allocation artifacts are
|
|
511
|
+
normalised out before hashing. Otherwise a master cloned by one import
|
|
512
|
+
would stop matching its own source, and every later import of a slide
|
|
513
|
+
from that source would clone yet another duplicate master/layout set.
|
|
514
|
+
"""
|
|
515
|
+
h = hashlib.sha256()
|
|
516
|
+
element = getattr(master_part, "_element", None)
|
|
517
|
+
if element is not None:
|
|
518
|
+
h.update(_normalized_master_xml(master_part, element))
|
|
519
|
+
else: # pragma: no cover - a master part is always an XmlPart
|
|
520
|
+
h.update(master_part.blob)
|
|
521
|
+
try:
|
|
522
|
+
theme_part = master_part.part_related_by(RT.THEME)
|
|
523
|
+
h.update(theme_part.blob)
|
|
524
|
+
except KeyError:
|
|
525
|
+
pass
|
|
526
|
+
return h.digest()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _normalized_master_xml(master_part: Part, master_elm) -> bytes:
|
|
530
|
+
"""Serialize *master_elm* with package-allocation artifacts normalised.
|
|
531
|
+
|
|
532
|
+
Drops the `p:sldLayoutIdLst` (its rId/id values are allocated per
|
|
533
|
+
package) and replaces every relationship-id attribute value (`r:embed`
|
|
534
|
+
etc. are renumbered when dependencies are copied) with a *stable token
|
|
535
|
+
derived from the referenced content* — the SHA-1 of the target part's
|
|
536
|
+
bytes, or the verbatim URL for an external target. Masking with a
|
|
537
|
+
constant would make two masters that differ only in a referenced image
|
|
538
|
+
(a different logo, say) fingerprint-identical and dedupe onto the wrong
|
|
539
|
+
branding; content-addressing keeps the hash stable across packages
|
|
540
|
+
while still distinguishing what the references point at.
|
|
541
|
+
"""
|
|
542
|
+
from lxml import etree
|
|
543
|
+
|
|
544
|
+
p_ns = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
545
|
+
clone = deepcopy(master_elm)
|
|
546
|
+
for layout_id_lst in clone.findall("{%s}sldLayoutIdLst" % p_ns):
|
|
547
|
+
clone.remove(layout_id_lst)
|
|
548
|
+
|
|
549
|
+
token_cache: dict[str, str] = {}
|
|
550
|
+
|
|
551
|
+
def _target_token(rId: str) -> str:
|
|
552
|
+
if rId in token_cache:
|
|
553
|
+
return token_cache[rId]
|
|
554
|
+
token = "unresolved"
|
|
555
|
+
try:
|
|
556
|
+
rel = master_part.rels[rId]
|
|
557
|
+
except KeyError:
|
|
558
|
+
rel = None
|
|
559
|
+
if rel is not None:
|
|
560
|
+
if rel.is_external:
|
|
561
|
+
token = "external:%s" % rel.target_ref
|
|
562
|
+
else:
|
|
563
|
+
token = hashlib.sha1(rel.target_part.blob).hexdigest()
|
|
564
|
+
token_cache[rId] = token
|
|
565
|
+
return token
|
|
566
|
+
|
|
567
|
+
r_prefix = "{%s}" % _R_NS
|
|
568
|
+
for el in clone.iter():
|
|
569
|
+
for attr_name, attr_value in list(el.attrib.items()):
|
|
570
|
+
if attr_name.startswith(r_prefix):
|
|
571
|
+
el.set(attr_name, _target_token(attr_value))
|
|
572
|
+
return etree.tostring(clone)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _partname_template(partname: PackURI) -> str:
|
|
576
|
+
"""Return a partname template string suitable for ``Package.next_partname()``.
|
|
577
|
+
|
|
578
|
+
E.g. ``/ppt/charts/chart3.xml`` → ``/ppt/charts/chart%d.xml``.
|
|
579
|
+
"""
|
|
580
|
+
# Split off the trailing number (if any) and extension
|
|
581
|
+
name = partname # str-like
|
|
582
|
+
# Find the last digit sequence before the extension
|
|
583
|
+
base = name.rsplit(".", 1)
|
|
584
|
+
if len(base) == 2:
|
|
585
|
+
stem, ext = base
|
|
586
|
+
else:
|
|
587
|
+
stem, ext = name, ""
|
|
588
|
+
|
|
589
|
+
# Strip trailing digits from stem to get the "root"
|
|
590
|
+
root = stem.rstrip("0123456789")
|
|
591
|
+
if not root.endswith("/") and root == stem:
|
|
592
|
+
# No trailing digits — use the whole stem
|
|
593
|
+
root = stem
|
|
594
|
+
|
|
595
|
+
if ext:
|
|
596
|
+
return f"{root}%d.{ext}"
|
|
597
|
+
return f"{root}%d"
|